mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: document authoring
This commit is contained in:
@ -34,6 +34,7 @@
|
|||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.8.0",
|
||||||
"react-pdf": "^7.1.1",
|
"react-pdf": "^7.1.1",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.1.6",
|
"typescript": "5.1.6",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,29 +2,15 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
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 { AddFieldsFormPartial } from '~/components/forms/edit-document/add-fields';
|
||||||
import { AddSignersFormPartial } from '~/components/forms/edit-document/add-signers';
|
import { AddSignersFormPartial } from '~/components/forms/edit-document/add-signers';
|
||||||
import { AddSubjectFormPartial } from '~/components/forms/edit-document/add-subject';
|
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 = {
|
export type EditDocumentFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
user: User;
|
||||||
@ -71,7 +57,7 @@ export const EditDocumentForm = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer document={documentUrl} />
|
<LazyPDFViewer document={documentUrl} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@ -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 { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
|
||||||
import { Header } from '~/components/(dashboard)/layout/header';
|
import { Header } from '~/components/(dashboard)/layout/header';
|
||||||
|
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||||
import { NextAuthProvider } from '~/providers/next-auth';
|
import { NextAuthProvider } from '~/providers/next-auth';
|
||||||
|
|
||||||
export type AuthenticatedDashboardLayoutProps = {
|
export type AuthenticatedDashboardLayoutProps = {
|
||||||
@ -30,6 +31,8 @@ export default async function AuthenticatedDashboardLayout({
|
|||||||
<Header user={user} />
|
<Header user={user} />
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
|
||||||
|
<RefreshOnFocus />
|
||||||
</NextAuthProvider>
|
</NextAuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
107
apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
Normal file
107
apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
94
apps/web/src/app/(signing)/sign/[token]/date-field.tsx
Normal file
94
apps/web/src/app/(signing)/sign/[token]/date-field.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
127
apps/web/src/app/(signing)/sign/[token]/form.tsx
Normal file
127
apps/web/src/app/(signing)/sign/[token]/form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
apps/web/src/app/(signing)/sign/[token]/layout.tsx
Normal file
24
apps/web/src/app/(signing)/sign/[token]/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
apps/web/src/app/(signing)/sign/[token]/name-field.tsx
Normal file
163
apps/web/src/app/(signing)/sign/[token]/name-field.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
94
apps/web/src/app/(signing)/sign/[token]/page.tsx
Normal file
94
apps/web/src/app/(signing)/sign/[token]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/web/src/app/(signing)/sign/[token]/provider.tsx
Normal file
63
apps/web/src/app/(signing)/sign/[token]/provider.tsx
Normal 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';
|
||||||
197
apps/web/src/app/(signing)/sign/[token]/signature-field.tsx
Normal file
197
apps/web/src/app/(signing)/sign/[token]/signature-field.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { Inter } from 'next/font/google';
|
import { Caveat, Inter } from 'next/font/google';
|
||||||
|
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||||
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
@ -10,6 +11,7 @@ import { PlausibleProvider } from '~/providers/plausible';
|
|||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||||
|
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||||
@ -37,7 +39,11 @@ export const metadata = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={fontInter.variable} suppressHydrationWarning>
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={cn(fontInter.variable, fontCaveat.variable)}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
<head>
|
<head>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
|||||||
BIN
apps/web/src/assets/signing-celebration.png
Normal file
BIN
apps/web/src/assets/signing-celebration.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 MiB |
@ -12,7 +12,7 @@ export type StackAvatarProps = {
|
|||||||
first?: boolean;
|
first?: boolean;
|
||||||
zIndex?: string;
|
zIndex?: string;
|
||||||
fallbackText?: string;
|
fallbackText?: string;
|
||||||
type: 'unsigned' | 'waiting' | 'completed';
|
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => {
|
export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => {
|
||||||
@ -28,6 +28,9 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr
|
|||||||
case 'unsigned':
|
case 'unsigned':
|
||||||
classes = 'bg-dawn-200 text-dawn-900';
|
classes = 'bg-dawn-200 text-dawn-900';
|
||||||
break;
|
break;
|
||||||
|
case 'opened':
|
||||||
|
classes = 'bg-yellow-200 text-yellow-700';
|
||||||
|
break;
|
||||||
case 'waiting':
|
case 'waiting':
|
||||||
classes = 'bg-water text-water-700';
|
classes = 'bg-water text-water-700';
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -13,15 +13,19 @@ import { StackAvatars } from './stack-avatars';
|
|||||||
|
|
||||||
export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[] }) => {
|
export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[] }) => {
|
||||||
const waitingRecipients = recipients.filter(
|
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(
|
const completedRecipients = recipients.filter(
|
||||||
(recipient) => recipient.sendStatus === 'SENT' && recipient.signingStatus === 'SIGNED',
|
(recipient) => getRecipientType(recipient) === 'completed',
|
||||||
);
|
);
|
||||||
|
|
||||||
const uncompletedRecipients = recipients.filter(
|
const uncompletedRecipients = recipients.filter(
|
||||||
(recipient) => recipient.sendStatus === 'NOT_SENT' && recipient.signingStatus === 'NOT_SIGNED',
|
(recipient) => getRecipientType(recipient) === 'unsigned',
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -66,6 +70,23 @@ export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[
|
|||||||
</div>
|
</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 && (
|
{uncompletedRecipients.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-medium">Uncompleted</h1>
|
<h1 className="text-base font-medium">Uncompleted</h1>
|
||||||
|
|||||||
@ -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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -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;
|
||||||
|
};
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import React, { HTMLAttributes } from 'react';
|
import React, { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Trash } from 'lucide-react';
|
import { Trash } from 'lucide-react';
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
@ -30,6 +32,8 @@ export type ProfileFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -59,6 +63,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
description: 'Your profile has been updated successfully.',
|
description: 'Your profile has been updated successfully.',
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
7
apps/web/src/components/motion.tsx
Normal file
7
apps/web/src/components/motion.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
export * from 'framer-motion';
|
||||||
|
|
||||||
|
export const MotionDiv = motion.div;
|
||||||
@ -24,7 +24,12 @@ export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChang
|
|||||||
onChange?: (_signatureDataUrl: string | null) => void;
|
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 $el = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
const [isPressed, setIsPressed] = useState(false);
|
const [isPressed, setIsPressed] = useState(false);
|
||||||
@ -127,7 +132,7 @@ export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProp
|
|||||||
setPoints(newPoints);
|
setPoints(newPoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($el.current) {
|
if ($el.current && newPoints.length > 0) {
|
||||||
const ctx = $el.current.getContext('2d');
|
const ctx = $el.current.getContext('2d');
|
||||||
|
|
||||||
if (ctx) {
|
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 (
|
return (
|
||||||
<div className="relative block">
|
<div className="relative block">
|
||||||
<canvas
|
<canvas
|
||||||
@ -202,10 +224,10 @@ export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProp
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute bottom-2 right-2">
|
<div className="absolute bottom-4 right-4">
|
||||||
<button
|
<button
|
||||||
type="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()}
|
onClick={() => onClearClick()}
|
||||||
>
|
>
|
||||||
Clear Signature
|
Clear Signature
|
||||||
|
|||||||
79
apps/web/src/hooks/use-field-page-coords.ts
Normal file
79
apps/web/src/hooks/use-field-page-coords.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -48,7 +48,7 @@ export default async function handler(
|
|||||||
// We had intended to do this with Zod but we can only validate it
|
// 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
|
// as a persistent file which does not include the properties that we
|
||||||
// need.
|
// 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);
|
resolve({ ...fields, ...files } as any);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
32
package-lock.json
generated
32
package-lock.json
generated
@ -85,6 +85,7 @@
|
|||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.8.0",
|
||||||
"react-pdf": "^7.1.1",
|
"react-pdf": "^7.1.1",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.1.6",
|
"typescript": "5.1.6",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
@ -95,6 +96,11 @@
|
|||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"apps/web/node_modules/ts-pattern": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-tL0w8U/pgaacOmkb9fRlYzWEUDCfVjjv9dD4wHTgZ61MjhuMt46VNWTG747NqW6vRzoWIKABVhFSOJ82FvXrfA=="
|
||||||
|
},
|
||||||
"node_modules/@aashutoshrathi/word-wrap": {
|
"node_modules/@aashutoshrathi/word-wrap": {
|
||||||
"version": "1.2.6",
|
"version": "1.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
|
||||||
@ -4067,6 +4073,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
|
||||||
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="
|
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/luxon": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-XOS5nBcgEeP2PpcqJHjCWhUCAzGfXIU8ILOSLpx2FhxqMW9KdxgCGXNOEKGVBfveKtIpztHzKK5vSRVLyW/NqA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/mdast": {
|
"node_modules/@types/mdast": {
|
||||||
"version": "3.0.12",
|
"version": "3.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.12.tgz",
|
||||||
@ -9412,6 +9424,14 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-7eDo4Pt7aGhoCheGFIuq4Xa2fJm4ZpmldpGhjTYBNUYNCN6TIEP6v7chwwwt3KRp7YR+rghbfvjyo3V5y9hgBw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/make-cancellable-promise": {
|
"node_modules/make-cancellable-promise": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-1.3.1.tgz",
|
||||||
@ -14789,17 +14809,25 @@
|
|||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"@upstash/redis": "^1.20.6",
|
"@upstash/redis": "^1.20.6",
|
||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
|
"luxon": "^3.4.0",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
"next-auth": "4.22.3",
|
"next-auth": "4.22.3",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"stripe": "^12.7.0"
|
"stripe": "^12.7.0",
|
||||||
|
"ts-pattern": "^5.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^5.0.0"
|
"@types/bcrypt": "^5.0.0",
|
||||||
|
"@types/luxon": "^3.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/lib/node_modules/ts-pattern": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-tL0w8U/pgaacOmkb9fRlYzWEUDCfVjjv9dD4wHTgZ61MjhuMt46VNWTG747NqW6vRzoWIKABVhFSOJ82FvXrfA=="
|
||||||
|
},
|
||||||
"packages/prettier-config": {
|
"packages/prettier-config": {
|
||||||
"name": "@documenso/prettier-config",
|
"name": "@documenso/prettier-config",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
|||||||
@ -29,7 +29,7 @@ module.exports = {
|
|||||||
rules: {
|
rules: {
|
||||||
'react/no-unescaped-entities': 'off',
|
'react/no-unescaped-entities': 'off',
|
||||||
|
|
||||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
|
||||||
// We never want to use `as` but are required to on occasion to handle
|
// We never want to use `as` but are required to on occasion to handle
|
||||||
|
|||||||
@ -1,10 +1,21 @@
|
|||||||
import { Recipient } from '@documenso/prisma/client';
|
import { ReadStatus, Recipient, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const getRecipientType = (recipient: Recipient) => {
|
export const getRecipientType = (recipient: Recipient) => {
|
||||||
if (recipient.sendStatus === 'SENT' && recipient.signingStatus === 'SIGNED') {
|
if (
|
||||||
|
recipient.sendStatus === SendStatus.SENT &&
|
||||||
|
recipient.signingStatus === SigningStatus.SIGNED
|
||||||
|
) {
|
||||||
return 'completed';
|
return 'completed';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
recipient.sendStatus === SendStatus.SENT &&
|
||||||
|
recipient.readStatus === ReadStatus.OPENED &&
|
||||||
|
recipient.signingStatus === SigningStatus.NOT_SIGNED
|
||||||
|
) {
|
||||||
|
return 'opened';
|
||||||
|
}
|
||||||
|
|
||||||
if (recipient.sendStatus === 'SENT' && recipient.signingStatus === 'NOT_SIGNED') {
|
if (recipient.sendStatus === 'SENT' && recipient.signingStatus === 'NOT_SIGNED') {
|
||||||
return 'waiting';
|
return 'waiting';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
|
import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
import { getServerSession as getNextAuthServerSession } from 'next-auth';
|
import { getServerSession as getNextAuthServerSession } from 'next-auth';
|
||||||
|
import { getToken } from 'next-auth/jwt';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
@ -27,6 +30,18 @@ export const getServerSession = async ({ req, res }: GetServerSessionOptions) =>
|
|||||||
return user;
|
return user;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getServerComponentToken = async () => {
|
||||||
|
const requestHeaders = Object.fromEntries(headers().entries());
|
||||||
|
|
||||||
|
const req = new NextRequest('http://example.com', {
|
||||||
|
headers: requestHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = await getToken({
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const getServerComponentSession = async () => {
|
export const getServerComponentSession = async () => {
|
||||||
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
|
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
|
||||||
|
|
||||||
|
|||||||
@ -10,23 +10,25 @@
|
|||||||
"universal/",
|
"universal/",
|
||||||
"next-auth/"
|
"next-auth/"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {},
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/email": "*",
|
"@documenso/email": "*",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
|
||||||
"@next-auth/prisma-adapter": "1.0.7",
|
"@next-auth/prisma-adapter": "1.0.7",
|
||||||
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"@upstash/redis": "^1.20.6",
|
"@upstash/redis": "^1.20.6",
|
||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"luxon": "^3.4.0",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
"next-auth": "4.22.3",
|
"next-auth": "4.22.3",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"stripe": "^12.7.0"
|
"stripe": "^12.7.0",
|
||||||
|
"ts-pattern": "^5.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^5.0.0"
|
"@types/bcrypt": "^5.0.0",
|
||||||
|
"@types/luxon": "^3.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,92 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { sealDocument } from './seal-document';
|
||||||
|
|
||||||
|
export type CompleteDocumentWithTokenOptions = {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const completeDocumentWithToken = async ({
|
||||||
|
token,
|
||||||
|
documentId,
|
||||||
|
}: CompleteDocumentWithTokenOptions) => {
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
Recipient: {
|
||||||
|
some: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: {
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
throw new Error(`Document ${document.id} has already been completed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.Recipient.length === 0) {
|
||||||
|
throw new Error(`Document ${document.id} has no recipient with token ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [recipient] = document.Recipient;
|
||||||
|
|
||||||
|
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||||
|
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = await prisma.field.findMany({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fields.some((field) => !field.inserted)) {
|
||||||
|
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.recipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipient.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
signedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documents = await prisma.document.updateMany({
|
||||||
|
where: {
|
||||||
|
id: document.id,
|
||||||
|
Recipient: {
|
||||||
|
every: {
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: DocumentStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('documents', documents);
|
||||||
|
|
||||||
|
if (documents.count > 0) {
|
||||||
|
console.log('sealing document');
|
||||||
|
sealDocument({ documentId: document.id });
|
||||||
|
}
|
||||||
|
};
|
||||||
30
packages/lib/server-only/document/get-document-by-token.ts
Normal file
30
packages/lib/server-only/document/get-document-by-token.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface GetDocumentAndSenderByTokenOptions {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDocumentAndSenderByToken = async ({
|
||||||
|
token,
|
||||||
|
}: GetDocumentAndSenderByTokenOptions) => {
|
||||||
|
const result = await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
Recipient: {
|
||||||
|
some: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
User: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||||
|
const { password: _password, ...User } = result.User;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
User,
|
||||||
|
};
|
||||||
|
};
|
||||||
74
packages/lib/server-only/document/seal-document.ts
Normal file
74
packages/lib/server-only/document/seal-document.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||||
|
|
||||||
|
export type SealDocumentOptions = {
|
||||||
|
documentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (document.status !== DocumentStatus.COMPLETED) {
|
||||||
|
throw new Error(`Document ${document.id} has not been completed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) {
|
||||||
|
throw new Error(`Document ${document.id} has unsigned recipients`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = await prisma.field.findMany({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Signature: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fields.some((field) => !field.inserted)) {
|
||||||
|
throw new Error(`Document ${document.id} has unsigned fields`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// !: Need to write the fields onto the document as a hard copy
|
||||||
|
const { document: pdfData } = document;
|
||||||
|
|
||||||
|
const doc = await PDFDocument.load(pdfData);
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
console.log('inserting field', {
|
||||||
|
...field,
|
||||||
|
Signature: null,
|
||||||
|
});
|
||||||
|
await insertFieldInPDF(doc, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBytes = await doc.save();
|
||||||
|
|
||||||
|
await prisma.document.update({
|
||||||
|
where: {
|
||||||
|
id: document.id,
|
||||||
|
status: DocumentStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
document: Buffer.from(pdfBytes).toString('base64'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -48,12 +48,15 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assetBaseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||||
|
const signDocumentLink = `${process.env.NEXT_PUBLIC_SITE_URL}/sign/${recipient.token}`;
|
||||||
|
|
||||||
const template = createElement(DocumentInviteEmailTemplate, {
|
const template = createElement(DocumentInviteEmailTemplate, {
|
||||||
documentName: document.title,
|
documentName: document.title,
|
||||||
assetBaseUrl: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
|
||||||
inviterName: user.name || undefined,
|
inviterName: user.name || undefined,
|
||||||
inviterEmail: user.email,
|
inviterEmail: user.email,
|
||||||
signDocumentLink: 'https://example.com',
|
assetBaseUrl,
|
||||||
|
signDocumentLink,
|
||||||
});
|
});
|
||||||
|
|
||||||
mailer.sendMail({
|
mailer.sendMail({
|
||||||
|
|||||||
29
packages/lib/server-only/document/viewed-document.ts
Normal file
29
packages/lib/server-only/document/viewed-document.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { ReadStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type ViewedDocumentOptions = {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewedDocument = async ({ token }: ViewedDocumentOptions) => {
|
||||||
|
const recipient = await prisma.recipient.findFirst({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
readStatus: ReadStatus.NOT_OPENED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
console.warn(`No recipient found for token ${token}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.recipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipient.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
readStatus: ReadStatus.OPENED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
18
packages/lib/server-only/field/get-fields-for-token.ts
Normal file
18
packages/lib/server-only/field/get-fields-for-token.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type GetFieldsForTokenOptions = {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) => {
|
||||||
|
return await prisma.field.findMany({
|
||||||
|
where: {
|
||||||
|
Recipient: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Signature: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type RemovedSignedFieldWithTokenOptions = {
|
||||||
|
token: string;
|
||||||
|
fieldId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeSignedFieldWithToken = async ({
|
||||||
|
token,
|
||||||
|
fieldId,
|
||||||
|
}: RemovedSignedFieldWithTokenOptions) => {
|
||||||
|
const field = await prisma.field.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: fieldId,
|
||||||
|
Recipient: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Document: true,
|
||||||
|
Recipient: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { Document: document, Recipient: recipient } = field;
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
throw new Error(`Document ${document.id} has already been completed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||||
|
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreachable code based on the above query but we need to satisfy TypeScript
|
||||||
|
if (field.recipientId === null) {
|
||||||
|
throw new Error(`Field ${fieldId} has no recipientId`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
prisma.field.update({
|
||||||
|
where: {
|
||||||
|
id: field.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.signature.deleteMany({
|
||||||
|
where: {
|
||||||
|
fieldId: field.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
89
packages/lib/server-only/field/sign-field-with-token.ts
Normal file
89
packages/lib/server-only/field/sign-field-with-token.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type SignFieldWithTokenOptions = {
|
||||||
|
token: string;
|
||||||
|
fieldId: number;
|
||||||
|
value: string;
|
||||||
|
isBase64?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const signFieldWithToken = async ({
|
||||||
|
token,
|
||||||
|
fieldId,
|
||||||
|
value,
|
||||||
|
isBase64,
|
||||||
|
}: SignFieldWithTokenOptions) => {
|
||||||
|
const field = await prisma.field.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: fieldId,
|
||||||
|
Recipient: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Document: true,
|
||||||
|
Recipient: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { Document: document, Recipient: recipient } = field;
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
throw new Error(`Document ${document.id} has already been completed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||||
|
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.inserted) {
|
||||||
|
throw new Error(`Field ${fieldId} has already been inserted`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreachable code based on the above query but we need to satisfy TypeScript
|
||||||
|
if (field.recipientId === null) {
|
||||||
|
throw new Error(`Field ${fieldId} has no recipientId`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSignatureField =
|
||||||
|
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
|
||||||
|
|
||||||
|
let customText = !isSignatureField ? value : undefined;
|
||||||
|
const signatureImageAsBase64 = isSignatureField && isBase64 ? value : undefined;
|
||||||
|
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
|
||||||
|
|
||||||
|
if (field.type === FieldType.DATE) {
|
||||||
|
customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.field.update({
|
||||||
|
where: {
|
||||||
|
id: field.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
customText,
|
||||||
|
inserted: true,
|
||||||
|
Signature: isSignatureField
|
||||||
|
? {
|
||||||
|
upsert: {
|
||||||
|
create: {
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
signatureImageAsBase64,
|
||||||
|
typedSignature,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
signatureImageAsBase64,
|
||||||
|
typedSignature,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
144
packages/lib/server-only/pdf/insert-field-in-pdf.ts
Normal file
144
packages/lib/server-only/pdf/insert-field-in-pdf.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import fontkit from '@pdf-lib/fontkit';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
||||||
|
|
||||||
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||||
|
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
|
|
||||||
|
const DEFAULT_STANDARD_FONT_SIZE = 15;
|
||||||
|
const DEFAULT_HANDWRITING_FONT_SIZE = 50;
|
||||||
|
|
||||||
|
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||||
|
const isSignatureField = isSignatureFieldType(field.type);
|
||||||
|
|
||||||
|
pdf.registerFontkit(fontkit);
|
||||||
|
|
||||||
|
const fontCaveat = readFileSync('./public/fonts/caveat.ttf');
|
||||||
|
|
||||||
|
const pages = pdf.getPages();
|
||||||
|
|
||||||
|
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
|
||||||
|
let fontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
|
||||||
|
|
||||||
|
const page = pages.at(field.page - 1);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new Error(`Page ${field.page} does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width: pageWidth, height: pageHeight } = page.getSize();
|
||||||
|
|
||||||
|
const fieldWidth = pageWidth * (Number(field.width) / 100);
|
||||||
|
const fieldHeight = pageHeight * (Number(field.height) / 100);
|
||||||
|
|
||||||
|
const fieldX = pageWidth * (Number(field.positionX) / 100);
|
||||||
|
const fieldY = pageHeight * (Number(field.positionY) / 100);
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
fieldWidth,
|
||||||
|
fieldHeight,
|
||||||
|
fieldX,
|
||||||
|
fieldY,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const font = await pdf.embedFont(isSignatureField ? fontCaveat : StandardFonts.Helvetica);
|
||||||
|
|
||||||
|
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
||||||
|
await pdf.embedFont(fontCaveat);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInsertingImage =
|
||||||
|
isSignatureField && typeof field.Signature?.signatureImageAsBase64 === 'string';
|
||||||
|
|
||||||
|
if (isSignatureField && isInsertingImage) {
|
||||||
|
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
|
||||||
|
|
||||||
|
let imageWidth = image.width;
|
||||||
|
let imageHeight = image.height;
|
||||||
|
|
||||||
|
const initialDimensions = {
|
||||||
|
width: imageWidth,
|
||||||
|
height: imageHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
||||||
|
|
||||||
|
imageWidth = imageWidth * scalingFactor;
|
||||||
|
imageHeight = imageHeight * scalingFactor;
|
||||||
|
|
||||||
|
const imageX = fieldX + (fieldWidth - imageWidth) / 2;
|
||||||
|
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
|
||||||
|
|
||||||
|
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||||
|
imageY = pageHeight - imageY - imageHeight;
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
initialDimensions,
|
||||||
|
scalingFactor,
|
||||||
|
imageWidth,
|
||||||
|
imageHeight,
|
||||||
|
imageX,
|
||||||
|
imageY,
|
||||||
|
});
|
||||||
|
|
||||||
|
page.drawImage(image, {
|
||||||
|
x: imageX,
|
||||||
|
y: imageY,
|
||||||
|
width: imageWidth,
|
||||||
|
height: imageHeight,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
|
||||||
|
const textHeight = font.heightAtSize(fontSize);
|
||||||
|
|
||||||
|
const initialDimensions = {
|
||||||
|
width: textWidth,
|
||||||
|
height: textHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
||||||
|
|
||||||
|
fontSize = Math.max(fontSize * scalingFactor, maxFontSize);
|
||||||
|
textWidth = font.widthOfTextAtSize(field.customText, fontSize);
|
||||||
|
|
||||||
|
const textX = fieldX + (fieldWidth - textWidth) / 2;
|
||||||
|
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
initialDimensions,
|
||||||
|
scalingFactor,
|
||||||
|
textWidth,
|
||||||
|
textHeight,
|
||||||
|
textX,
|
||||||
|
textY,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||||
|
textY = pageHeight - textY - textHeight;
|
||||||
|
|
||||||
|
page.drawText(field.customText, {
|
||||||
|
x: textX,
|
||||||
|
y: textY,
|
||||||
|
size: fontSize,
|
||||||
|
font,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pdf;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertFieldInPDFBytes = async (
|
||||||
|
pdf: ArrayBuffer | Uint8Array | string,
|
||||||
|
field: FieldWithSignature,
|
||||||
|
) => {
|
||||||
|
const pdfDoc = await PDFDocument.load(pdf);
|
||||||
|
|
||||||
|
await insertFieldInPDF(pdfDoc, field);
|
||||||
|
|
||||||
|
return await pdfDoc.save();
|
||||||
|
};
|
||||||
13
packages/lib/server-only/recipient/get-recipient-by-token.ts
Normal file
13
packages/lib/server-only/recipient/get-recipient-by-token.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface GetRecipientByTokenOptions {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRecipientByToken = async ({ token }: GetRecipientByTokenOptions) => {
|
||||||
|
return await prisma.recipient.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
9
packages/prisma/guards/is-signature-field.ts
Normal file
9
packages/prisma/guards/is-signature-field.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
|
||||||
|
const SignatureFieldTypes = [FieldType.SIGNATURE, FieldType.FREE_SIGNATURE] as const;
|
||||||
|
|
||||||
|
type SignatureFieldType = (typeof SignatureFieldTypes)[number];
|
||||||
|
|
||||||
|
export const isSignatureFieldType = (type: FieldType): type is SignatureFieldType => {
|
||||||
|
return type === FieldType.SIGNATURE || type === FieldType.FREE_SIGNATURE;
|
||||||
|
};
|
||||||
5
packages/prisma/types/field-with-signature.ts
Normal file
5
packages/prisma/types/field-with-signature.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Field, Signature } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type FieldWithSignature = Field & {
|
||||||
|
Signature?: Signature | null;
|
||||||
|
};
|
||||||
@ -9,6 +9,7 @@ module.exports = {
|
|||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['var(--font-sans)', ...fontFamily.sans],
|
sans: ['var(--font-sans)', ...fontFamily.sans],
|
||||||
|
signature: ['var(--font-signature)'],
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
border: 'hsl(var(--border))',
|
border: 'hsl(var(--border))',
|
||||||
|
|||||||
54
packages/trpc/server/field-router/router.ts
Normal file
54
packages/trpc/server/field-router/router.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token';
|
||||||
|
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
|
||||||
|
|
||||||
|
import { procedure, router } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZRemovedSignedFieldWithTokenMutationSchema,
|
||||||
|
ZSignFieldWithTokenMutationSchema,
|
||||||
|
} from './schema';
|
||||||
|
|
||||||
|
export const fieldRouter = router({
|
||||||
|
signFieldWithToken: procedure
|
||||||
|
.input(ZSignFieldWithTokenMutationSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const { token, fieldId, value, isBase64 } = input;
|
||||||
|
|
||||||
|
return await signFieldWithToken({
|
||||||
|
token,
|
||||||
|
fieldId,
|
||||||
|
value,
|
||||||
|
isBase64,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to sign this field. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeSignedFieldWithToken: procedure
|
||||||
|
.input(ZRemovedSignedFieldWithTokenMutationSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const { token, fieldId } = input;
|
||||||
|
|
||||||
|
return await removeSignedFieldWithToken({
|
||||||
|
token,
|
||||||
|
fieldId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to remove the signature for this field. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
19
packages/trpc/server/field-router/schema.ts
Normal file
19
packages/trpc/server/field-router/schema.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZSignFieldWithTokenMutationSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
fieldId: z.number(),
|
||||||
|
value: z.string(),
|
||||||
|
isBase64: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSignFieldWithTokenMutationSchema = z.infer<typeof ZSignFieldWithTokenMutationSchema>;
|
||||||
|
|
||||||
|
export const ZRemovedSignedFieldWithTokenMutationSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
fieldId: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TRemovedSignedFieldWithTokenMutationSchema = z.infer<
|
||||||
|
typeof ZRemovedSignedFieldWithTokenMutationSchema
|
||||||
|
>;
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { authRouter } from './auth-router/router';
|
import { authRouter } from './auth-router/router';
|
||||||
import { documentRouter } from './document-router/router';
|
import { documentRouter } from './document-router/router';
|
||||||
|
import { fieldRouter } from './field-router/router';
|
||||||
import { profileRouter } from './profile-router/router';
|
import { profileRouter } from './profile-router/router';
|
||||||
import { procedure, router } from './trpc';
|
import { procedure, router } from './trpc';
|
||||||
|
|
||||||
@ -8,6 +9,7 @@ export const appRouter = router({
|
|||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
profile: profileRouter,
|
profile: profileRouter,
|
||||||
document: documentRouter,
|
document: documentRouter,
|
||||||
|
field: fieldRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
35
packages/ui/primitives/element-visible.tsx
Normal file
35
packages/ui/primitives/element-visible.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export type ElementVisibleProps = {
|
||||||
|
target: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ElementVisible = ({ target, children }: ElementVisibleProps) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver((_mutations) => {
|
||||||
|
const $el = document.querySelector(target);
|
||||||
|
|
||||||
|
setVisible(!!$el);
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [target]);
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user