Compare commits

..

13 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan 5ef3523d89 fix: increase table col span to center text 2023-07-10 02:43:28 +00:00
Ephraim Atta-Duncan a27c6c3eb2 refactor: use tailwind in styling icons 2023-07-05 20:53:08 +00:00
Ephraim Atta-Duncan a5b3969f56 revert: include time in document created 2023-07-05 20:49:20 +00:00
Ephraim Atta-Duncan f61bf00702 feat: pass document id as prop to the actions component 2023-07-04 00:59:25 +00:00
Ephraim Atta-Duncan 86b0d5ee9c feat: add actions to all documents table 2023-07-04 00:32:11 +00:00
Ephraim Atta-Duncan 1dd1bb617d feat: add action buttons to documents table 2023-07-04 00:29:26 +00:00
Mythie 60b150cc58 fix: add shadow to metric-card 2023-06-24 15:01:18 +10:00
Mythie bd0db0f8fd feat: email templates
adds email templates using `react-email` which will be used for invites,
signing and document completion.

authored by @dephraiim
2023-06-24 14:59:08 +10:00
Mythie d8a094a324 chore: use jsonprotocol 2023-06-23 18:06:02 +10:00
Lucas Smith 4b063b68ab Merge pull request #214 from doug-andrade/refresh/dashboard-filters
linking card metrics to filtered /documents
2023-06-22 08:09:16 +10:00
Doug Andrade 3c02331cb9 linking card metrics to filtered /documents 2023-06-21 17:57:02 -04:00
Mythie eea09dcfac feat: persist fields and recipients for document editing 2023-06-21 23:49:23 +10:00
Mythie 3aea62e898 fix: styling and semantic updates 2023-06-21 23:48:22 +10:00
67 changed files with 6708 additions and 2027 deletions
-7
View File
@@ -17,10 +17,3 @@ NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false
# This is only required for the marketing site
NEXT_PRIVATE_REDIS_URL=
NEXT_PRIVATE_REDIS_TOKEN=
# Mailserver
NEXT_PRIVATE_SENDGRID_API_KEY=
NEXT_PRIVATE_SMTP_MAIL_HOST=
NEXT_PRIVATE_SMTP_MAIL_PORT=
NEXT_PRIVATE_SMTP_MAIL_USER=
NEXT_PRIVATE_SMTP_MAIL_PASSWORD=
+3 -1
View File
@@ -43,7 +43,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</head>
<body>
<PlausibleProvider>{children}</PlausibleProvider>
<PlausibleProvider>
{children}
</PlausibleProvider>
<Toaster />
</body>
</html>
+2 -4
View File
@@ -21,12 +21,11 @@
"framer-motion": "^10.12.8",
"lucide-react": "^0.214.0",
"micro": "^10.0.1",
"nanoid": "^4.0.2",
"next": "13.4.1",
"next-auth": "^4.22.1",
"next-plausible": "^3.7.2",
"next-themes": "^0.2.1",
"nodemailer": "^6.9.3",
"nodemailer-sendgrid": "^1.0.3",
"perfect-freehand": "^1.2.0",
"react": "18.2.0",
"react-dom": "18.2.0",
@@ -34,14 +33,13 @@
"react-hook-form": "^7.43.9",
"react-icons": "^4.8.0",
"react-pdf": "^7.1.1",
"react-rnd": "^10.4.1",
"typescript": "5.0.4",
"zod": "^3.21.4"
},
"devDependencies": {
"@types/formidable": "^2.0.6",
"@types/node": "20.1.0",
"@types/nodemailer": "^6.4.8",
"@types/nodemailer-sendgrid": "^1.0.0",
"@types/react": "18.2.6",
"@types/react-dom": "18.2.4"
}
-6
View File
@@ -11,11 +11,5 @@ declare namespace NodeJS {
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: string;
NEXT_PRIVATE_SENDGRID_API_KEY: string;
NEXT_PRIVATE_SMTP_MAIL_HOST: string;
NEXT_PRIVATE_SMTP_MAIL_PORT: string;
NEXT_PRIVATE_SMTP_MAIL_USER: string;
NEXT_PRIVATE_SMTP_MAIL_PASSWORD: string;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 B

@@ -15,6 +15,7 @@ import {
} from '@documenso/ui/primitives/table';
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
import { ActionButtons } from '~/components/(dashboard)/table/actions-component';
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
@@ -38,9 +39,15 @@ export default async function DashboardPage() {
<h1 className="text-4xl font-semibold">Dashboard</h1>
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
<CardMetric icon={FileCheck} title="Completed" value={stats.COMPLETED} />
<CardMetric icon={File} title="Drafts" value={stats.DRAFT} />
<CardMetric icon={Clock} title="Pending" value={stats.PENDING} />
<Link href={'/documents?status=COMPLETED'} passHref>
<CardMetric icon={FileCheck} title="Completed" value={stats.COMPLETED} />
</Link>
<Link href={'/documents?status=DRAFT'} passHref>
<CardMetric icon={File} title="Drafts" value={stats.DRAFT} />
</Link>
<Link href={'/documents?status=PENDING'} passHref>
<CardMetric icon={Clock} title="Pending" value={stats.PENDING} />
</Link>
</div>
<div className="mt-12">
@@ -55,7 +62,8 @@ export default async function DashboardPage() {
<TableHead className="w-[100px]">ID</TableHead>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Created</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -73,14 +81,17 @@ export default async function DashboardPage() {
<TableCell>
<DocumentStatus status={document.status} />
</TableCell>
<TableCell className="text-right">
<TableCell>
<LocaleDate date={document.created} />
</TableCell>
<TableCell className="flex cursor-pointer gap-6">
<ActionButtons documentId={document.id} />
</TableCell>
</TableRow>
))}
{results.data.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
<TableCell colSpan={5} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
@@ -5,6 +5,8 @@ import { ChevronLeft } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { EditDocumentForm } from '~/components/forms/edit-document';
@@ -34,6 +36,17 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
redirect('/documents');
}
const [recipients, fields] = await Promise.all([
await getRecipientsForDocument({
documentId,
userId: session.id,
}),
await getFieldsForDocument({
documentId,
userId: session.id,
}),
]);
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
@@ -48,7 +61,13 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
{document.title}
</h1>
<EditDocumentForm className="mt-8" document={document} user={session} />
<EditDocumentForm
className="mt-8"
document={document}
user={session}
recipients={recipients}
fields={fields}
/>
</div>
);
}
@@ -12,6 +12,7 @@ import { Document } from '@documenso/prisma/client';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { ActionButtons } from '~/components/(dashboard)/table/actions-component';
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
@@ -59,6 +60,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
accessorKey: 'created',
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
},
{
header: 'Actions',
accessorKey: 'actions',
cell: ({ row }) => <ActionButtons documentId={row.original.id} />,
},
]}
data={results.data}
perPage={results.perPage}
-132
View File
@@ -1,132 +0,0 @@
'use client';
import React, { useState } from 'react';
import { trpc } from '@documenso/trpc/react';
import { TSendMailMutationSchema } from '@documenso/trpc/server/mail-router/schema';
export default function Send() {
const { mutateAsync: sendMail } = trpc.mail.send.useMutation();
const [form, setForm] = useState<TSendMailMutationSchema>({
email: '',
type: 'invite',
documentName: '',
name: '',
firstName: '',
documentSigningLink: '',
downloadLink: '',
numberOfSigners: 1,
reviewLink: '',
});
const handleInputChange = (event: { target: { name: any; value: unknown } }) => {
setForm({
...form,
[event.target.name]: event.target.value,
});
};
const handleSubmit = async (event: { preventDefault: () => void }) => {
event.preventDefault();
console.log('clicked');
await sendMail(form);
alert('sent');
};
return (
<div className="p-20">
<form onSubmit={handleSubmit}>
<input
type="text"
name="email"
placeholder="Email"
value={form.email}
onChange={handleInputChange}
required
className="my-2 block rounded-md border-2 border-gray-300 p-2"
/>
<input
type="text"
name="type"
placeholder="Type"
value={form.type}
onChange={handleInputChange}
required
className="my-2 block rounded-md border-2 border-gray-300 p-2"
/>
<input
type="text"
name="documentName"
placeholder="Document Name"
value={form.documentName}
onChange={handleInputChange}
required
className="my-2 block rounded-md border-2 border-gray-300 p-2"
/>
<input
type="text"
name="name"
placeholder="Name"
value={form.name}
onChange={handleInputChange}
required
className="my-2 block rounded-md border-2 border-gray-300 p-2"
/>
<input
type="text"
name="firstName"
placeholder="First Name"
value={form.firstName}
onChange={handleInputChange}
required
className="my-2 block rounded-md border-2 border-gray-300 p-2"
/>
<input
type="text"
name="documentSigningLink"
placeholder="Document Signing Link"
value={form.documentSigningLink}
onChange={handleInputChange}
required
className="my-2 block rounded-md border-2 border-gray-300 p-2"
/>
<input
type="text"
name="downloadLink"
placeholder="Download Link"
value={form.downloadLink}
onChange={handleInputChange}
required
className="my-2 block rounded-md border-2 border-gray-300 p-2"
/>
<input
type="number"
name="numberOfSigners"
placeholder="Number of Signers"
value={form.numberOfSigners}
onChange={handleInputChange}
required
className="my-2 block rounded-md border-2 border-gray-300 p-2"
/>
<input
type="text"
name="reviewLink"
placeholder="Review Link"
value={form.reviewLink}
onChange={handleInputChange}
required
className="my-2 block rounded-md border-2 border-gray-300 p-2"
/>
<button
type="submit"
className="mt-4 rounded-md border-2 border-solid border-black px-4 py-2 text-2xl"
>
Send
</button>
</form>
</div>
);
}
@@ -110,7 +110,7 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
)}
gradient={true}
degrees={120}
lightMode={theme === 'light'}
{...getRootProps()}
{...props}
>
@@ -13,7 +13,12 @@ export type CardMetricProps = {
export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricProps) => {
return (
<div className={cn('border-border bg-background overflow-hidden rounded-lg border', className)}>
<div
className={cn(
'border-border bg-background overflow-hidden rounded-lg border shadow shadow-transparent duration-200 hover:shadow-slate-100',
className,
)}
>
<div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
<div className="flex items-start">
{Icon && <Icon className="mr-2 h-4 w-4 text-slate-500" />}
@@ -67,16 +67,6 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
const pageX = event.clientX - left;
const pageY = event.clientY - top;
console.log({
pageNumber,
numPages,
originalEvent: event,
pageHeight: height,
pageWidth: width,
pageX,
pageY,
});
if (onPageClick) {
onPageClick({
pageNumber,
@@ -137,6 +127,8 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
<PDFPage
pageNumber={i + 1}
width={width}
renderAnnotationLayer={false}
renderTextLayer={false}
onClick={(e) => onDocumentPageClick(e, i + 1)}
/>
</div>
@@ -0,0 +1,2 @@
export const PDF_VIEWER_CONTAINER_SELECTOR = '.react-pdf__Document';
export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page';
@@ -0,0 +1,30 @@
'use client';
import React from 'react';
import { Download, Edit, Trash } from 'lucide-react';
export function ActionButtons({ documentId }: { documentId: number }) {
return (
<div className="flex cursor-pointer gap-6">
<Edit
className="text-primary h-5 w-5"
onClick={() => {
console.log('Edit Document with id: ', documentId);
}}
/>
<Download
className="text-primary h-5 w-5"
onClick={() => {
console.log('Download Document with id: ', documentId);
}}
/>
<Trash
className="text-primary h-5 w-5"
onClick={() => {
console.log('Delete Document with id: ', documentId);
}}
/>
</div>
);
}
@@ -41,16 +41,16 @@ export const Callout = () => {
</span>
</Button>
<Link
href="https://github.com/documenso/documenso"
target="_blank"
onClick={() => event('view-github')}
>
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm" asChild>
<Link
href="https://github.com/documenso/documenso"
target="_blank"
onClick={() => event('view-github')}
>
<Github className="mr-2 h-5 w-5" />
Star on Github
</Button>
</Link>
</Link>
</Button>
</div>
);
};
+11 -4
View File
@@ -114,12 +114,19 @@ export const Hero = ({ className, ...props }: HeroProps) => {
</span>
</Button>
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
<Button
variant="outline"
className="rounded-full bg-transparent backdrop-blur-sm"
asChild
>
<Link
href="https://github.com/documenso/documenso"
onClick={() => event('view-github')}
>
<Github className="mr-2 h-5 w-5" />
Star on Github
</Button>
</Link>
</Link>
</Button>
</motion.div>
<motion.div
@@ -93,14 +93,15 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
For small teams and individuals who need a simple solution
</p>
<Link
href="https://github.com/documenso/documenso"
target="_blank"
className="mt-6"
onClick={() => event('view-github')}
>
<Button className="rounded-full text-base">View on Github</Button>
</Link>
<Button className="mt-6 rounded-full text-base">
<Link
href="https://github.com/documenso/documenso"
target="_blank"
onClick={() => event('view-github')}
>
View on Github
</Link>
</Button>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="py-4 font-medium text-slate-900">Host your own instance</p>
@@ -1,11 +1,10 @@
import { AnimatePresence, motion } from 'framer-motion';
import { FieldError } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils';
export type FormErrorMessageProps = {
className?: string;
error: FieldError | undefined;
error: { message?: string } | undefined;
};
export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => {
+140 -33
View File
@@ -1,21 +1,22 @@
'use client';
import { useState } from 'react';
import { useId, useState } from 'react';
import dynamic from 'next/dynamic';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { useTheme } from 'next-themes';
import { useForm } from 'react-hook-form';
import { Document, User } from '@documenso/prisma/client';
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from './edit-document/add-fields';
import { AddSignersFormPartial } from './edit-document/add-signers';
import { AddSubjectFormPartial } from './edit-document/add-subject';
import { TEditDocumentFormSchema, ZEditDocumentFormSchema } from './edit-document/types';
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
@@ -35,57 +36,143 @@ export type EditDocumentFormProps = {
className?: string;
user: User;
document: Document;
recipients: Recipient[];
fields: Field[];
};
export const EditDocumentForm = ({ className, document, user: _user }: EditDocumentFormProps) => {
const documentUrl = `data:application/pdf;base64,${document.document}`;
export const EditDocumentForm = ({
className,
document,
recipients,
fields,
user: _user,
}: EditDocumentFormProps) => {
const initialId = useId();
const [step, setStep] = useState(0);
const [nextStepLoading, setNextStepLoading] = useState(false);
const documentUrl = `data:application/pdf;base64,${document.document}`;
const defaultSigners =
recipients.length > 0
? recipients.map((recipient) => ({
nativeId: recipient.id,
formId: `${recipient.id}-${recipient.documentId}`,
name: recipient.name,
email: recipient.email,
}))
: [
{
formId: initialId,
name: '',
email: '',
},
];
const defaultFields = fields.map((field) => ({
nativeId: field.id,
formId: `${field.id}-${field.documentId}`,
pageNumber: field.page,
type: field.type,
pageX: Number(field.positionX),
pageY: Number(field.positionY),
pageWidth: Number(field.width),
pageHeight: Number(field.height),
signerEmail: recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
}));
const { mutateAsync: setRecipientsForDocument } =
trpc.document.setRecipientsForDocument.useMutation();
const { mutateAsync: setFieldsForDocument } = trpc.document.setFieldsForDocument.useMutation();
const {
control,
// handleSubmit,
handleSubmit,
watch,
formState: { errors, isSubmitting, isValid },
trigger,
formState: { errors, isSubmitting },
} = useForm<TEditDocumentFormSchema>({
mode: 'onBlur',
defaultValues: {
signers: [
{
name: '',
email: '',
},
],
signers: defaultSigners,
fields: defaultFields,
email: {
subject: '',
message: '',
},
},
resolver: zodResolver(ZEditDocumentFormSchema),
});
const { theme } = useTheme();
const signersFormValue = watch('signers');
const fieldsFormValue = watch('fields');
console.log({ state: watch(), errors });
const canGoBack = step > 0;
const canGoNext = isValid && step < MAX_STEP;
const canGoNext = step < MAX_STEP;
const onGoBackClick = () => setStep((prev) => Math.max(0, prev - 1));
const onGoNextClick = () => setStep((prev) => Math.min(MAX_STEP, prev + 1));
const onGoNextClick = async () => {
setNextStepLoading(true);
const passes = await trigger();
if (step === 0) {
await setRecipientsForDocument({
documentId: document.id,
recipients: signersFormValue.map((signer) => ({
id: signer.nativeId ?? undefined,
name: signer.name,
email: signer.email,
})),
}).catch((err: unknown) => console.error(err));
}
if (step === 1) {
await setFieldsForDocument({
documentId: document.id,
fields: fieldsFormValue.map((field) => ({
id: field.nativeId ?? undefined,
type: field.type,
signerEmail: field.signerEmail,
pageNumber: field.pageNumber,
pageX: field.pageX,
pageY: field.pageY,
pageWidth: field.pageWidth,
pageHeight: field.pageHeight,
})),
}).catch((err: unknown) => console.error(err));
}
if (passes) {
setStep((prev) => Math.min(MAX_STEP, prev + 1));
}
console.log({ passes });
setNextStepLoading(false);
};
return (
<div className={cn('grid w-full grid-cols-12 gap-x-8', className)}>
<Card
className="col-span-7 rounded-xl before:rounded-xl"
gradient
lightMode={theme === 'light'}
>
<Card className="col-span-7 rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewer document={documentUrl} />
</CardContent>
</Card>
<div className="relative col-span-5">
<div className="dark:bg-background border-border sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border bg-[hsl(var(--widget))] px-4 py-6">
<div className="col-span-5">
<form
className="dark:bg-background border-border sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border bg-[hsl(var(--widget))] px-4 py-6"
onSubmit={handleSubmit(console.log)}
>
{step === 0 && (
<AddSignersFormPartial
className="-mx-2 flex-1 overflow-y-hidden px-2"
control={control}
watch={watch}
errors={errors}
isSubmitting={isSubmitting}
/>
@@ -98,7 +185,16 @@ export const EditDocumentForm = ({ className, document, user: _user }: EditDocum
watch={watch}
errors={errors}
isSubmitting={isSubmitting}
theme={theme || 'dark'}
/>
)}
{step === 2 && (
<AddSubjectFormPartial
className="-mx-2 flex-1 overflow-y-hidden px-2"
control={control}
watch={watch}
errors={errors}
isSubmitting={isSubmitting}
/>
)}
@@ -118,6 +214,7 @@ export const EditDocumentForm = ({ className, document, user: _user }: EditDocum
<div className="mt-4 flex gap-x-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
size="lg"
variant="secondary"
@@ -127,17 +224,27 @@ export const EditDocumentForm = ({ className, document, user: _user }: EditDocum
Go Back
</Button>
<Button
className="bg-documenso flex-1"
size="lg"
disabled={!canGoNext}
onClick={onGoNextClick}
>
Continue
</Button>
{step < MAX_STEP && (
<Button
type="button"
className="bg-documenso flex-1"
size="lg"
disabled={!canGoNext}
onClick={onGoNextClick}
>
{nextStepLoading && <Loader className="mr-2 h-5 w-5 animate-spin" />}
Continue
</Button>
)}
{step === MAX_STEP && (
<Button type="submit" className="bg-documenso flex-1" size="lg">
Complete
</Button>
)}
</div>
</div>
</div>
</form>
</div>
</div>
);
@@ -1,12 +1,14 @@
'use client';
import { useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Caveat } from 'next/font/google';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Control, FieldErrors, UseFormWatch } from 'react-hook-form';
import { nanoid } from 'nanoid';
import { Control, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form';
import { FieldType } 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';
@@ -19,7 +21,10 @@ import {
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { TEditDocumentFormSchema } from './types';
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
import { FieldItem } from './field-item';
import { FRIENDLY_FIELD_TYPE, TEditDocumentFormSchema } from './types';
const fontCaveat = Caveat({
weight: ['500'],
@@ -28,30 +33,285 @@ const fontCaveat = Caveat({
variable: '--font-caveat',
});
const DEFAULT_HEIGHT_PERCENT = 5;
const DEFAULT_WIDTH_PERCENT = 15;
const MIN_HEIGHT_PX = 60;
const MIN_WIDTH_PX = 200;
export type AddFieldsFormProps = {
className?: string;
control: Control<TEditDocumentFormSchema>;
watch: UseFormWatch<TEditDocumentFormSchema>;
errors: FieldErrors<TEditDocumentFormSchema>;
isSubmitting: boolean;
theme: string;
};
export const AddFieldsFormPartial = ({
className,
control: _control,
control: control,
watch,
errors: _errors,
isSubmitting: _isSubmitting,
theme,
}: AddFieldsFormProps) => {
const signers = watch('signers');
const fields = watch('fields');
const { append, remove, update } = useFieldArray({
control,
name: 'fields',
});
const [selectedSigner, setSelectedSigner] = useState(() => signers[0]);
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({
x: 0,
y: 0,
});
const fieldBounds = useRef({
height: 0,
width: 0,
});
const isWithinPageBounds = useCallback((event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) {
return false;
}
const target = event.target;
const $page =
target.closest(PDF_VIEWER_PAGE_SELECTOR) ?? target.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
return false;
}
const { top, left, height, width } = $page.getBoundingClientRect();
if (event.clientY > top + height || event.clientY < top) {
return false;
}
if (event.clientX > left + width || event.clientX < left) {
return false;
}
return true;
}, []);
const onMouseMove = useCallback(
(event: MouseEvent) => {
if (!isWithinPageBounds(event)) {
setVisible(false);
return;
}
setVisible(true);
setCoords({
x: event.clientX - fieldBounds.current.width / 2,
y: event.clientY - fieldBounds.current.height / 2,
});
},
[isWithinPageBounds],
);
const onMouseClick = useCallback(
(event: MouseEvent) => {
if (!selectedField) {
return;
}
if (!(event.target instanceof HTMLElement)) {
return;
}
const target = event.target;
const $page =
target.closest<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR) ??
target.querySelector<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR);
if (!$page || !isWithinPageBounds(event)) {
return;
}
const { height, width } = $page.getBoundingClientRect();
const top = $page.offsetTop;
const left = $page.offsetLeft;
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height
let pageX = ((event.pageX - left) / width) * 100;
let pageY = ((event.pageY - top) / height) * 100;
// Get the bounds as a percentage of the page width and height
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
// And center it based on the bounds
pageX -= fieldPageWidth / 2;
pageY -= fieldPageHeight / 2;
append({
formId: nanoid(12),
type: selectedField,
pageNumber,
pageX,
pageY,
pageWidth: fieldPageWidth,
pageHeight: fieldPageHeight,
signerEmail: selectedSigner.email,
});
setVisible(false);
setSelectedField(null);
},
[append, isWithinPageBounds, selectedField, selectedSigner.email],
);
const onFieldResize = useCallback(
(node: HTMLElement, index: number) => {
const field = fields[index];
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page) {
return;
}
const { height: pageHeight, width: pageWidth } = $page.getBoundingClientRect();
const pageTop = $page.offsetTop;
const pageLeft = $page.offsetLeft;
let { top: nodeTop, left: nodeLeft } = node.getBoundingClientRect();
const { height, width } = node.getBoundingClientRect();
nodeTop += window.scrollY;
nodeLeft += window.scrollX;
// Calculate width and height as a percentage of the page width and height
const newWidth = (width / pageWidth) * 100;
const newHeight = (height / pageHeight) * 100;
// Calculate the new position as a percentage of the page width and height
const newX = ((nodeLeft - pageLeft) / pageWidth) * 100;
const newY = ((nodeTop - pageTop) / pageHeight) * 100;
update(index, {
...field,
pageX: newX,
pageY: newY,
pageWidth: newWidth,
pageHeight: newHeight,
});
},
[fields, update],
);
const onFieldMove = useCallback(
(node: HTMLElement, index: number) => {
const field = fields[index];
const $page = document.querySelector(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page || !($page instanceof HTMLElement)) {
return;
}
const { height: pageHeight, width: pageWidth } = $page.getBoundingClientRect();
const pageTop = $page.offsetTop;
const pageLeft = $page.offsetLeft;
let { top: nodeTop, left: nodeLeft } = node.getBoundingClientRect();
nodeTop += window.scrollY;
nodeLeft += window.scrollX;
// Calculate the new position as a percentage of the page width and height
const newX = ((nodeLeft - pageLeft) / pageWidth) * 100;
const newY = ((nodeTop - pageTop) / pageHeight) * 100;
update(index, {
...field,
pageX: newX,
pageY: newY,
});
},
[fields, update],
);
useEffect(() => {
if (selectedField) {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('click', onMouseClick);
}
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('click', onMouseClick);
};
}, [onMouseClick, onMouseMove, selectedField]);
useEffect(() => {
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
return;
}
const { height, width } = $page.getBoundingClientRect();
fieldBounds.current = {
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
};
}, []);
return (
<div className={cn('flex flex-col', className)}>
<h3 className="text-2xl font-semibold">Edit Document</h3>
{selectedField && visible && (
<Card
className="border-primary pointer-events-none fixed z-50 cursor-pointer bg-white"
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<CardContent className="text-foreground flex h-full w-full items-center justify-center p-2">
{FRIENDLY_FIELD_TYPE[selectedField]}
</CardContent>
</Card>
)}
{fields.map((field, index) => (
<FieldItem
key={index}
field={field}
disabled={selectedSigner.email !== field.signerEmail}
minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width}
passive={visible && !!selectedField}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
/>
))}
<h3 className="text-2xl font-semibold">Add Fields</h3>
<p className="text-muted-foreground mt-2 text-sm">
Add all relevant fields for each recipient.
@@ -62,6 +322,7 @@ export const AddFieldsFormPartial = ({
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className="bg-background text-muted-foreground justify-between font-normal"
@@ -87,6 +348,7 @@ export const AddFieldsFormPartial = ({
{signers.map((signer, index) => (
<CommandItem key={index} onSelect={() => setSelectedSigner(signer)}>
<Check
aria-hidden={signer !== selectedSigner}
className={cn('mr-2 h-4 w-4', {
'opacity-0': signer !== selectedSigner,
'opacity-100': signer === selectedSigner,
@@ -108,15 +370,17 @@ export const AddFieldsFormPartial = ({
<div className="-mx-2 mt-8 flex-1 overflow-y-scroll px-2">
<div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-8">
<button className="group h-full w-full">
<Card
className="group-focus:border-documenso h-full w-full cursor-pointer"
lightMode={theme === 'light'}
>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.SIGNATURE)}
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-focus:text-foreground text-3xl font-medium',
'text-muted-foreground group-data-[selected]:text-foreground text-3xl font-medium',
fontCaveat.className,
)}
>
@@ -128,15 +392,17 @@ export const AddFieldsFormPartial = ({
</Card>
</button>
<button className="group h-full w-full">
<Card
className="group-focus:border-documenso h-full w-full cursor-pointer"
lightMode={theme === 'light'}
>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.EMAIL)}
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-focus:text-foreground text-xl font-medium',
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Email'}
@@ -147,15 +413,17 @@ export const AddFieldsFormPartial = ({
</Card>
</button>
<button className="group h-full w-full">
<Card
className="group-focus:border-documenso h-full w-full cursor-pointer"
lightMode={theme === 'light'}
>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.NAME)}
data-selected={selectedField === FieldType.NAME ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-focus:text-foreground text-xl font-medium',
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Name'}
@@ -166,15 +434,17 @@ export const AddFieldsFormPartial = ({
</Card>
</button>
<button className="group h-full w-full">
<Card
className="group-focus:border-documenso h-full w-full cursor-pointer"
lightMode={theme === 'light'}
>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.DATE)}
data-selected={selectedField === FieldType.DATE ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-focus:text-foreground text-xl font-medium',
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Date'}
@@ -1,8 +1,11 @@
'use client';
import React from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react';
import { Control, Controller, FieldErrors, useFieldArray } from 'react-hook-form';
import { nanoid } from 'nanoid';
import { Control, Controller, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -16,6 +19,7 @@ import { TEditDocumentFormSchema } from './types';
export type AddSignersFormProps = {
className?: string;
control: Control<TEditDocumentFormSchema>;
watch: UseFormWatch<TEditDocumentFormSchema>;
errors: FieldErrors<TEditDocumentFormSchema>;
isSubmitting: boolean;
};
@@ -27,14 +31,49 @@ export const AddSignersFormPartial = ({
isSubmitting,
}: AddSignersFormProps) => {
const {
append,
append: appendSigner,
fields: signers,
remove,
remove: removeSigner,
} = useFieldArray({
control,
name: 'signers',
});
const { remove: removeField, fields: fields } = useFieldArray({
name: 'fields',
control,
});
const onAddSigner = () => {
appendSigner({
formId: nanoid(12),
name: '',
email: '',
});
};
const onRemoveSigner = (index: number) => {
const signer = signers[index];
removeSigner(index);
const fieldsToRemove: number[] = [];
fields.forEach((field, fieldIndex) => {
if (field.signerEmail === signer.email) {
fieldsToRemove.push(fieldIndex);
}
});
removeField(fieldsToRemove);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
onAddSigner();
}
};
return (
<div className={cn('flex flex-col', className)}>
<h3 className="text-foreground text-2xl font-semibold">Add Signers</h3>
@@ -45,23 +84,27 @@ export const AddSignersFormPartial = ({
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col overflow-y-scroll px-2">
<div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">
<div className="flex w-full flex-col gap-y-4">
<AnimatePresence>
{signers.map((field, index) => (
<motion.div key={field.id} className="flex flex-wrap items-end gap-x-4">
{signers.map((signer, index) => (
<motion.div key={signer.formId} className="flex flex-wrap items-end gap-x-4">
<div className="flex-1">
<Label htmlFor={`signer-${index}-email`}>Email</Label>
<Label htmlFor={`signer-${signer.formId}-email`}>
Email
<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Controller
control={control}
name={`signers.${index}.email`}
render={({ field }) => (
<Input
id={`signer-${index}-email`}
id={`signer-${signer.formId}-email`}
type="email"
className="bg-background mt-2"
disabled={isSubmitting}
onKeyDown={onKeyDown}
{...field}
/>
)}
@@ -69,17 +112,18 @@ export const AddSignersFormPartial = ({
</div>
<div className="flex-1">
<Label htmlFor={`signer-${index}-name`}>Name</Label>
<Label htmlFor={`signer-${signer.formId}-name`}>Name</Label>
<Controller
control={control}
name={`signers.${index}.name`}
render={({ field }) => (
<Input
id={`signer-${index}-name`}
id={`signer-${signer.formId}-name`}
type="text"
className="bg-background mt-2"
disabled={isSubmitting}
onKeyDown={onKeyDown}
{...field}
/>
)}
@@ -89,9 +133,9 @@ export const AddSignersFormPartial = ({
<div>
<button
type="button"
className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80"
disabled={isSubmitting}
onClick={() => remove(index)}
className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitting || signers.length === 1}
onClick={() => onRemoveSigner(index)}
>
<Trash className="h-5 w-5" />
</button>
@@ -106,17 +150,10 @@ export const AddSignersFormPartial = ({
</AnimatePresence>
</div>
<FormErrorMessage className="mt-2" error={errors.signers} />
<div className="mt-4">
<Button
type="button"
disabled={isSubmitting}
onClick={() =>
append({
email: '',
name: '',
})
}
>
<Button type="button" disabled={isSubmitting} onClick={() => onAddSigner()}>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Signer
</Button>
@@ -0,0 +1,111 @@
'use client';
import { Control, Controller, FieldErrors, UseFormWatch } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { FormErrorMessage } from '~/components/form/form-error-message';
import { TEditDocumentFormSchema } from './types';
export type AddSubjectFormProps = {
className?: string;
control: Control<TEditDocumentFormSchema>;
watch: UseFormWatch<TEditDocumentFormSchema>;
errors: FieldErrors<TEditDocumentFormSchema>;
isSubmitting: boolean;
};
export const AddSubjectFormPartial = ({
className,
control,
errors,
isSubmitting,
}: AddSubjectFormProps) => {
return (
<div className={cn('flex flex-col', className)}>
<h3 className="text-foreground text-2xl font-semibold">Add Subject</h3>
<p className="text-muted-foreground mt-2 text-sm">
Add the subject and message you wish to send to signers.
</p>
<hr className="border-border mb-8 mt-4" />
<div className="flex flex-col gap-y-4">
<div>
<Label htmlFor="subject">
Subject <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name="email.subject"
render={({ field }) => (
<Input
id="subject"
// placeholder="Subject"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
/>
)}
/>
<FormErrorMessage className="mt-2" errors={errors} />
</div>
<div>
<Label htmlFor="message">
Message <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name="email.message"
render={({ field }) => (
<Textarea
id="message"
className="bg-background mt-2 h-32 resize-none"
disabled={isSubmitting}
{...field}
/>
)}
/>
<FormErrorMessage className="mt-2" errors={errors} />
</div>
<div>
<p className="text-muted-foreground text-sm">
You can use the following variables in your message:
</p>
<ul className="mt-2 flex list-inside list-disc flex-col gap-y-2 text-sm">
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{signer.name}'}
</code>{' '}
- The signer's name
</li>
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{signer.email}'}
</code>{' '}
- The signer's email
</li>
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{document.name}'}
</code>{' '}
- The document's name
</li>
</ul>
</div>
</div>
</div>
);
};
@@ -0,0 +1,149 @@
import { useCallback, useEffect, useState } from 'react';
import { X } from 'lucide-react';
import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
import { FRIENDLY_FIELD_TYPE, TEditDocumentFormSchema } from './types';
type Field = TEditDocumentFormSchema['fields'][0];
export type FieldItemProps = {
field: Field;
passive?: boolean;
disabled?: boolean;
minHeight?: number;
minWidth?: number;
onResize?: (_node: HTMLElement) => void;
onMove?: (_node: HTMLElement) => void;
onRemove?: () => void;
};
export const FieldItem = ({
field,
passive,
disabled,
minHeight,
minWidth,
onResize,
onMove,
onRemove,
}: FieldItemProps) => {
const [active, setActive] = useState(false);
const [coords, setCoords] = useState({
pageX: 0,
pageY: 0,
pageHeight: 0,
pageWidth: 0,
});
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page) {
return;
}
const { height, width } = $page.getBoundingClientRect();
const top = $page.offsetTop;
const left = $page.offsetLeft;
// X and Y are percentages of the page's height and width
const pageX = (field.pageX / 100) * width + left;
const pageY = (field.pageY / 100) * height + top;
const pageHeight = (field.pageHeight / 100) * height;
const pageWidth = (field.pageWidth / 100) * width;
setCoords({
pageX: pageX,
pageY: pageY,
pageHeight: pageHeight,
pageWidth: pageWidth,
});
}, [field.pageHeight, field.pageNumber, field.pageWidth, field.pageX, field.pageY]);
useEffect(() => {
calculateCoords();
}, [calculateCoords]);
useEffect(() => {
const onResize = () => {
calculateCoords();
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateCoords]);
return createPortal(
<Rnd
key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth}
className={cn('absolute z-20', {
'pointer-events-none': passive,
'pointer-events-none opacity-75': disabled,
})}
minHeight={minHeight}
minWidth={minWidth}
default={{
x: coords.pageX,
y: coords.pageY,
height: coords.pageHeight,
width: coords.pageWidth,
}}
bounds={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`}
onDragStart={() => setActive(true)}
onResizeStart={() => setActive(true)}
onResizeStop={(_e, _d, ref) => {
setActive(false);
onResize?.(ref);
}}
onDragStop={(_e, d) => {
setActive(false);
onMove?.(d.node);
}}
>
{!disabled && (
<button
className="bg-destructive absolute -right-2 -top-2 z-[9999] flex h-5 w-5 items-center justify-center rounded-full"
onClick={() => onRemove?.()}
>
<X className="text-destructive-foreground h-4 w-4" />
</button>
)}
<Card
className={cn('hover:border-primary/50 h-full w-full bg-white', {
'border-primary hover:border-primary': active,
})}
>
<CardContent
className={cn(
'text-foreground flex h-full w-full flex-col items-center justify-center p-2',
{
'text-muted-foreground/50': disabled,
},
)}
>
{FRIENDLY_FIELD_TYPE[field.type]}
<p className="text-muted-foreground/50 w-full truncate text-center text-xs">
{field.signerEmail}
</p>
</CardContent>
</Card>
</Rnd>,
document.body,
);
};
@@ -0,0 +1,53 @@
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,13 +1,49 @@
import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
export const ZEditDocumentFormSchema = z.object({
signers: z.array(
signers: z
.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z.string().min(1).email(),
name: z.string(),
}),
)
.refine((signers) => {
const emails = signers.map((signer) => signer.email);
return new Set(emails).size === emails.length;
}, 'Signers must have unique emails'),
fields: z.array(
z.object({
id: z.number().optional(),
email: z.string().min(1).email(),
name: z.string(),
formId: z.string().min(1),
nativeId: z.number().optional(),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
}),
),
email: z.object({
subject: z.string(),
message: z.string(),
}),
});
export type TEditDocumentFormSchema = z.infer<typeof ZEditDocumentFormSchema>;
export const FRIENDLY_FIELD_TYPE: Record<FieldType, string> = {
[FieldType.SIGNATURE]: 'Signature',
[FieldType.FREE_SIGNATURE]: 'Free Signature',
[FieldType.TEXT]: 'Text',
[FieldType.DATE]: 'Date',
[FieldType.EMAIL]: 'Email',
[FieldType.NAME]: 'Name',
};
+4917 -1410
View File
File diff suppressed because it is too large Load Diff
+4 -10
View File
@@ -5,17 +5,13 @@
"dev": "turbo run dev --filter=@documenso/{web,marketing}",
"start": "cd apps && cd web && next start",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"db:migrate": "prisma migrate dev",
"docker:compose-up": "docker-compose -p documenso -f ./docker/compose-without-app.yml up -d",
"dx": "npm install && run-s docker:compose-up db:migrate && npm run dev"
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"devDependencies": {
"dotenv": "^16.0.3",
"dotenv-cli": "^7.2.1",
"eslint": "^7.32.0",
"eslint-config-custom": "*",
"npm-run-all": "^4.1.5",
"prettier": "^2.5.1",
"turbo": "^1.9.3"
},
@@ -23,9 +19,7 @@
"packageManager": "npm@8.19.2",
"workspaces": [
"apps/*",
"packages/*"
],
"prisma": {
"schema": "packages/prisma/schema.prisma"
}
"packages/*",
"packages/email/.react-email"
]
}
+1
View File
@@ -0,0 +1 @@
.react-email/
+1
View File
@@ -0,0 +1 @@
declare module '@documenso/tailwind-config';
+1
View File
@@ -0,0 +1 @@
export { render, renderAsync } from '@react-email/components';
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@documenso/email",
"version": "1.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "MIT",
"files": [
"templates/"
],
"scripts": {
"dev": "email dev --port 3002 --dir templates"
},
"dependencies": {
"@documenso/tsconfig": "*",
"@documenso/tailwind-config": "*",
"@documenso/ui": "*",
"@react-email/components": "^0.0.7"
},
"devDependencies": {
"react-email": "^1.9.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 B

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 B

+11
View File
@@ -0,0 +1,11 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const baseConfig = require('@documenso/tailwind-config');
const path = require('path');
module.exports = {
...baseConfig,
content: [
`templates/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
],
};
@@ -0,0 +1,129 @@
import {
Body,
Button,
Container,
Head,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from '@react-email/components';
import * as config from '@documenso/tailwind-config';
interface DocumentCompletedEmailTemplateProps {
downloadLink?: string;
reviewLink?: string;
documentName?: string;
assetBaseUrl?: string;
}
export const DocumentCompletedEmailTemplate = ({
downloadLink = 'https://documenso.com',
reviewLink = 'https://documenso.com',
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
}: DocumentCompletedEmailTemplateProps) => {
const previewText = `Completed Document`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
<Section className="p-2">
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="h-6" />
<Section className="mt-4 flex-row items-center justify-center">
<div className="flex items-center justify-center p-4">
<Img
className="h-42"
src={getAssetUrl('/static/document.png')}
alt="Documenso"
/>
</div>
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
<Img
src={getAssetUrl('/static/completed.png')}
className="-mb-0.5 mr-2 inline h-7 w-7"
/>
Completed
</Text>
<Text className="text-primary mb-0 text-center text-lg font-semibold">
{documentName} was signed by all signers
</Text>
<Text className="my-1 text-center text-base text-slate-400">
Continue by downloading or reviewing the document.
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href={reviewLink}
>
<Img
src={getAssetUrl('/static/review.png')}
className="-mb-1 mr-2 inline h-5 w-5"
/>
Review
</Button>
<Button
className="inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href={downloadLink}
>
<Img
src={getAssetUrl('/static/download.png')}
className="-mb-1 mr-2 inline h-5 w-5"
/>
Download
</Button>
</Section>
</Section>
</Section>
</Container>
<Container className="mx-auto max-w-xl">
<Section>
<Text className="my-4 text-base text-slate-400">
This document was sent using{' '}
<Link className="text-[#7AC455]" href="https://documenso.com">
Documenso.
</Link>
</Text>
<Text className="my-8 text-sm text-slate-400">
Documenso
<br />
2261 Market Street, #5211, San Francisco, CA 94114, USA
</Text>
</Section>
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};
export default DocumentCompletedEmailTemplate;
@@ -0,0 +1,127 @@
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from '@react-email/components';
import * as config from '@documenso/tailwind-config';
interface DocumentInviteEmailTemplateProps {
inviterName?: string;
inviterEmail?: string;
documentName?: string;
signDocumentLink?: string;
assetBaseUrl?: string;
}
export const DocumentInviteEmailTemplate = ({
inviterName = 'Lucas Smith',
inviterEmail = 'lucas@documenso.com',
documentName = 'Open Source Pledge.pdf',
signDocumentLink = 'https://documenso.com',
assetBaseUrl = 'http://localhost:3002',
}: DocumentInviteEmailTemplateProps) => {
const previewText = `Completed Document`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
<Section className="p-2">
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="h-6" />
<Section className="mt-4 flex-row items-center justify-center">
<div className="flex items-center justify-center p-4">
<Img
className="h-42"
src={getAssetUrl('/static/document.png')}
alt="Documenso"
/>
</div>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{inviterName} has invited you to sign "{documentName}"
</Text>
<Text className="my-1 text-center text-base text-slate-400">
Continue by signing the document.
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={signDocumentLink}
>
Sign Document
</Button>
</Section>
</Section>
</Section>
</Container>
<Container className="mx-auto mt-12 max-w-xl">
<Section>
<Text className="my-4 text-base font-semibold">
{inviterName}{' '}
<Link className="font-normal text-slate-400" href="mailto:{inviterEmail}">
({inviterEmail})
</Link>
</Text>
<Text className="mt-2 text-base text-slate-400">
{inviterName} has invited you to sign the document "{documentName}".
</Text>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<Section>
<Text className="my-4 text-base text-slate-400">
This document was sent using{' '}
<Link className="text-[#7AC455]" href="https://documenso.com">
Documenso.
</Link>
</Text>
<Text className="my-8 text-sm text-slate-400">
Documenso
<br />
2261 Market Street, #5211, San Francisco, CA 94114, USA
</Text>
</Section>
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};
export default DocumentInviteEmailTemplate;
@@ -0,0 +1,104 @@
import {
Body,
Button,
Container,
Head,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from '@react-email/components';
import * as config from '@documenso/tailwind-config';
interface DocumentPendingEmailTemplateProps {
documentName?: string;
assetBaseUrl?: string;
}
export const DocumentPendingEmailTemplate = ({
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
}: DocumentPendingEmailTemplateProps) => {
const previewText = `Pending Document`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
<Section className="p-2">
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="h-6" />
<Section className="mt-4 flex-row items-center justify-center">
<div className="flex items-center justify-center p-4">
<Img
className="h-42"
src={getAssetUrl('/static/document.png')}
alt="Documenso"
/>
</div>
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-blue-500">
<Img
src={getAssetUrl('/static/clock.png')}
className="-mb-0.5 mr-2 inline h-7 w-7"
/>
Waiting for others
</Text>
<Text className="text-primary mb-0 text-center text-lg font-semibold">
{documentName} has been signed
</Text>
<Text className="mx-auto mb-6 mt-1 max-w-[80%] text-center text-base text-slate-400">
We're still waiting for other signers to sign this document.
<br />
We'll notify you as soon as it's ready.
</Text>
</Section>
</Section>
</Container>
<Container className="mx-auto max-w-xl">
<Section>
<Text className="my-4 text-base text-slate-400">
This document was sent using{' '}
<Link className="text-[#7AC455]" href="https://documenso.com">
Documenso.
</Link>
</Text>
<Text className="my-8 text-sm text-slate-400">
Documenso
<br />
2261 Market Street, #5211, San Francisco, CA 94114, USA
</Text>
</Section>
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};
export default DocumentPendingEmailTemplate;
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "@documenso/tsconfig/react-library.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}
-199
View File
@@ -1,199 +0,0 @@
import * as React from 'react';
import {
Body,
Button,
Container,
Head,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
render,
} from '@react-email/components';
interface DocumensoEmailProps {
email?: string;
name?: string;
firstName?: string;
documentSigningLink?: string;
documentName?: string;
downloadLink?: string;
reviewLink?: string;
numberOfSigners?: number;
type: 'invite' | 'signed' | 'completed';
}
export const DocumensoEmail = ({
documentSigningLink = 'https://documenso.com',
downloadLink = 'https://documenso.com',
reviewLink = 'https://documenso.com',
email = 'duncan@documenso.com',
name = 'Ephraim Atta-Duncan',
firstName = 'Ephraim',
documentName = 'Open Source Pledge.pdf',
numberOfSigners = 2,
type = 'signed',
}: DocumensoEmailProps) => {
const previewText = type === 'completed' ? 'Completed Document' : `Sign Document`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto ml-auto mr-auto font-sans">
<Section className="bg-white">
<Container
style={{
border: '2px solid #eaeaea',
}}
className="mx-auto mb-[10px] ml-auto mr-auto mt-[40px] w-[600px] rounded-lg p-[10px] backdrop-blur-sm"
>
<Section>
<Img
src={`http://localhost:3000/static/logo.png`}
alt="Documenso Logo"
width={120}
/>
<Section className="mt-4 flex-row items-center justify-center">
<div className="my-3 flex items-center justify-center">
<Img
className="ml-[160px]" // Works on most of the email clients
src={`http://localhost:3000/static/document.png`}
alt="Documenso"
/>
</div>
{type === 'completed' && (
<Text className="mb-4 text-center text-[16px] font-semibold text-[#7AC455]">
<Img
src="http://localhost:3000/static/completed.png"
className="-mb-0.5 mr-1.5 inline"
/>
Completed
</Text>
)}
{type === 'signed' && (
<Text className="mb-4 text-center text-[16px] font-semibold text-[#3879C5]">
<Img
src="http://localhost:3000/static/clock.png"
className="-mb-0.5 mr-1.5 inline"
/>
Waiting for {numberOfSigners} {numberOfSigners === 1 ? 'person' : 'people'} to
sign
</Text>
)}
<Text className="mx-0 mb-0 text-center text-[16px] font-semibold text-[#27272A]">
{type === 'invite'
? `${name} has invited you to sign “${documentName}`
: `${documentName}” was signed by ${name}`}
</Text>
<Text className="my-1 text-center text-[14px] text-[#AFAFAF]">
{type === 'invite'
? 'Continue by signing the document.'
: 'Continue by downloading or reviewing the document.'}
</Text>
<Section className="mb-[24px] mt-[32px] text-center">
{type === 'invite' && (
<Button
pX={20}
pY={12}
className="rounded bg-[#A2E771] text-center text-[14px] font-medium text-black no-underline"
href={documentSigningLink}
>
Sign Document
</Button>
)}
{type !== 'invite' && (
<Section>
<Button
pX={18}
pY={10}
style={{
border: '1px solid #E9E9E9',
}}
className="mr-4 rounded-lg text-center text-[14px] font-medium text-black no-underline"
href={reviewLink}
>
<Img
src="http://localhost:3000/static/review.png"
className="-mb-0.5 mr-1 inline"
/>
Review
</Button>
<Button
pX={18}
pY={10}
style={{
border: '1px solid #E9E9E9',
}}
className="rounded-lg text-center text-[14px] font-medium text-black no-underline"
href={downloadLink}
>
<Img
src="http://localhost:3000/static/download.png"
className="-mb-0.5 mr-1 inline"
/>
Download
</Button>
</Section>
)}
</Section>
</Section>
</Section>
</Container>
<Container className="mx-auto ml-auto mr-auto w-[600px]">
<Section>
{type === 'invite' && (
<>
<Text className="text-[18px] leading-[24px] text-black">
{name} <span className="font-semibold text-[#AFAFAF]">({email})</span>
</Text>
<Text className="mb-[40px] text-[16px] leading-[28px] text-[#AFAFAF]">
Hi,
<br />
Please sign the attached document. Magna magna adipisicing dolore minim et
aliquip ipsum esse ut nulla ad sint irure.
<br /> - {firstName}
</Text>
</>
)}
<Text className="my-[40px] text-[14px] text-[#AFAFAF]">
This document was sent using{' '}
<Link className="text-[#7AC455] underline" href="https://documenso.com">
Documenso.
</Link>
</Text>
<Text className="my-[40px] text-[14px] text-[#AFAFAF]">
Documenso
<br />
2261 Market Street, #5211, San Francisco, CA 94114, USA
</Text>
</Section>
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};
export const emailHtml = (props: DocumensoEmailProps) =>
render(<DocumensoEmail {...props} />, {
pretty: true,
});
export const emailText = (props: DocumensoEmailProps) =>
render(<DocumensoEmail {...props} />, {
plainText: true,
});
-1
View File
@@ -30,7 +30,6 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
const user = await getUserByEmail({ email }).catch(() => null);
if (!user || !user.password) {
console.log('no user');
return null;
}
+5 -5
View File
@@ -10,18 +10,18 @@
"universal/",
"next-auth/"
],
"scripts": {},
"scripts": {
},
"dependencies": {
"@documenso/prisma": "*",
"@next-auth/prisma-adapter": "^1.0.6",
"@pdf-lib/fontkit": "^1.1.1",
"@react-email/components": "^0.0.7",
"@react-email/render": "^0.0.7",
"@next-auth/prisma-adapter": "^1.0.6",
"@upstash/redis": "^1.20.6",
"bcrypt": "^5.1.0",
"pdf-lib": "^1.17.1",
"nanoid": "^4.0.2",
"next": "13.4.1",
"next-auth": "^4.22.1",
"pdf-lib": "^1.17.1",
"stripe": "^12.7.0"
},
"devDependencies": {
@@ -0,0 +1,19 @@
import { prisma } from '@documenso/prisma';
export interface GetFieldsForDocumentOptions {
documentId: number;
userId: number;
}
export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForDocumentOptions) => {
const fields = await prisma.field.findMany({
where: {
documentId,
Document: {
userId,
},
},
});
return fields;
};
@@ -0,0 +1,127 @@
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetFieldsForDocumentOptions {
userId: number;
documentId: number;
fields: {
id?: number | null;
signerEmail: string;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
}[];
}
export const setFieldsForDocument = async ({
userId,
documentId,
fields,
}: SetFieldsForDocumentOptions) => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
userId,
},
});
if (!document) {
throw new Error('Document not found');
}
const existingFields = await prisma.field.findMany({
where: {
documentId,
},
include: {
Recipient: true,
},
});
const removedFields = existingFields.filter(
(existingField) =>
!fields.find(
(field) =>
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
),
);
const linkedFields = fields.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
return {
...field,
...existing,
};
});
for (const field of linkedFields) {
if (
field.Recipient?.sendStatus === SendStatus.SENT ||
field.Recipient?.signingStatus === SigningStatus.SIGNED
) {
throw new Error('Cannot modify fields after sending');
}
}
const persistedFields = await prisma.$transaction(
linkedFields.map((field) =>
field.id
? prisma.field.update({
where: {
id: field.id,
recipientId: field.recipientId,
documentId,
},
data: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
},
})
: prisma.field.create({
data: {
type: field.type!,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
customText: '',
inserted: false,
Document: {
connect: {
id: document.id,
},
},
Recipient: {
connect: {
documentId_email: {
documentId: document.id,
email: field.signerEmail,
},
},
},
},
}),
),
);
if (removedFields.length > 0) {
await prisma.field.deleteMany({
where: {
id: {
in: removedFields.map((field) => field.id),
},
},
});
}
return persistedFields;
};
-51
View File
@@ -1,51 +0,0 @@
import nodemailer from 'nodemailer';
import nodemailerSendgrid from 'nodemailer-sendgrid';
import { TSendMailMutationSchema } from '@documenso/trpc/server/mail-router/schema';
import { emailHtml, emailText } from '../../mail/template';
interface SendMail {
template: TSendMailMutationSchema;
mail: {
from: string;
subject: string;
};
}
export const sendMail = async ({ template, mail }: SendMail) => {
let transporter;
if (process.env.NEXT_PRIVATE_SENDGRID_API_KEY) {
transporter = nodemailer.createTransport(
nodemailerSendgrid({
apiKey: process.env.NEXT_PRIVATE_SENDGRID_API_KEY,
}),
);
}
if (process.env.NEXT_PRIVATE_SMTP_MAIL_HOST) {
transporter = nodemailer.createTransport({
host: process.env.NEXT_PRIVATE_SMTP_MAIL_HOST,
port: Number(process.env.NEXT_PRIVATE_SMTP_MAIL_PORT),
auth: {
user: process.env.NEXT_PRIVATE_SMTP_MAIL_USER,
pass: process.env.NEXT_PRIVATE_SMTP_MAIL_PASSWORD,
},
});
}
if (!transporter) {
throw new Error(
'No mail transport configured. Probably Sendgrid API Key nor SMTP Mail host was set',
);
}
await transporter.sendMail({
from: mail.from,
to: template.email,
subject: mail.subject,
text: emailText({ ...template }),
html: emailHtml({ ...template }),
});
};
@@ -0,0 +1,22 @@
import { prisma } from '@documenso/prisma';
export interface GetRecipientsForDocumentOptions {
documentId: number;
userId: number;
}
export const getRecipientsForDocument = async ({
documentId,
userId,
}: GetRecipientsForDocumentOptions) => {
const recipients = await prisma.recipient.findMany({
where: {
documentId,
Document: {
userId,
},
},
});
return recipients;
};
@@ -0,0 +1,103 @@
import { nanoid } from 'nanoid';
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetRecipientsForDocumentOptions {
userId: number;
documentId: number;
recipients: {
id?: number | null;
email: string;
name: string;
}[];
}
export const setRecipientsForDocument = async ({
userId,
documentId,
recipients,
}: SetRecipientsForDocumentOptions) => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
userId,
},
});
if (!document) {
throw new Error('Document not found');
}
const existingRecipients = await prisma.recipient.findMany({
where: {
documentId,
},
});
const removedRecipients = existingRecipients.filter(
(existingRecipient) =>
!recipients.find(
(recipient) =>
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
),
);
const linkedRecipients = recipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
return {
...recipient,
...existing,
};
});
for (const recipient of linkedRecipients) {
if (
recipient.sendStatus === SendStatus.SENT ||
recipient.signingStatus === SigningStatus.SIGNED
) {
throw new Error('Cannot modify recipients after sending');
}
}
const persistedRecipients = await prisma.$transaction(
linkedRecipients.map((recipient) =>
recipient.id
? prisma.recipient.update({
where: {
id: recipient.id,
documentId,
},
data: {
name: recipient.name,
email: recipient.email,
documentId,
},
})
: prisma.recipient.create({
data: {
name: recipient.name,
email: recipient.email,
token: nanoid(),
documentId,
},
}),
),
);
if (removedRecipients.length > 0) {
await prisma.recipient.deleteMany({
where: {
id: {
in: removedRecipients.map((recipient) => recipient.id),
},
},
});
}
return persistedRecipients;
};
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "FieldType" ADD VALUE 'NAME';
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "FieldType" ADD VALUE 'EMAIL';
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Field" ADD COLUMN "height" INTEGER NOT NULL DEFAULT -1,
ADD COLUMN "width" INTEGER NOT NULL DEFAULT -1;
@@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[documentId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Recipient_documentId_email_key" ON "Recipient"("documentId", "email");
@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "Field" ALTER COLUMN "positionX" SET DEFAULT 0,
ALTER COLUMN "positionX" SET DATA TYPE DECIMAL(65,30),
ALTER COLUMN "positionY" SET DEFAULT 0,
ALTER COLUMN "positionY" SET DATA TYPE DECIMAL(65,30),
ALTER COLUMN "height" SET DEFAULT -1,
ALTER COLUMN "height" SET DATA TYPE DECIMAL(65,30),
ALTER COLUMN "width" SET DEFAULT -1,
ALTER COLUMN "width" SET DATA TYPE DECIMAL(65,30);
+9 -2
View File
@@ -1,5 +1,6 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["extendedWhereUnique", "jsonProtocol"]
}
datasource db {
@@ -123,11 +124,15 @@ model Recipient {
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
Field Field[]
Signature Signature[]
@@unique([documentId, email])
}
enum FieldType {
SIGNATURE
FREE_SIGNATURE
NAME
EMAIL
DATE
TEXT
}
@@ -138,8 +143,10 @@ model Field {
recipientId Int?
type FieldType
page Int
positionX Int @default(0)
positionY Int @default(0)
positionX Decimal @default(0)
positionY Decimal @default(0)
width Decimal @default(-1)
height Decimal @default(-1)
customText String
inserted Boolean
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
+2
View File
@@ -7,6 +7,8 @@
"scripts": {
},
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@tanstack/react-query": "^4.29.5",
"@trpc/client": "^10.25.1",
"@trpc/next": "^10.25.1",
@@ -0,0 +1,55 @@
import { TRPCError } from '@trpc/server';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { authenticatedProcedure, router } from '../trpc';
import {
ZSetFieldsForDocumentMutationSchema,
ZSetRecipientsForDocumentMutationSchema,
} from './schema';
export const documentRouter = router({
setRecipientsForDocument: authenticatedProcedure
.input(ZSetRecipientsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, recipients } = input;
return await setRecipientsForDocument({
userId: ctx.user.id,
documentId,
recipients,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to set the recipients for this document. Please try again later.',
});
}
}),
setFieldsForDocument: authenticatedProcedure
.input(ZSetFieldsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, fields } = input;
return await setFieldsForDocument({
userId: ctx.user.id,
documentId,
fields,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to set the fields for this document. Please try again later.',
});
}
}),
});
@@ -0,0 +1,38 @@
import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
export const ZSetRecipientsForDocumentMutationSchema = z.object({
documentId: z.number(),
recipients: z.array(
z.object({
id: z.number().nullish(),
email: z.string().min(1).email(),
name: z.string(),
}),
),
});
export type TSetRecipientsForDocumentMutationSchema = z.infer<
typeof ZSetRecipientsForDocumentMutationSchema
>;
export const ZSetFieldsForDocumentMutationSchema = z.object({
documentId: z.number(),
fields: z.array(
z.object({
id: z.number().nullish(),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
}),
),
});
export type TSetFieldsForDocumentMutationSchema = z.infer<
typeof ZSetFieldsForDocumentMutationSchema
>;
@@ -1,27 +0,0 @@
import { TRPCError } from '@trpc/server';
import { sendMail } from '@documenso/lib/server-only/mail/send';
import { authenticatedProcedure, router } from '../trpc';
import { ZSendMailMutationSchema } from './schema';
export const mailRouter = router({
send: authenticatedProcedure.input(ZSendMailMutationSchema).mutation(async ({ input }) => {
try {
return await sendMail({
template: input,
mail: {
from: '<hi@documenso>',
subject: 'Documeso Invite',
},
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to send an email.',
});
}
}),
});
@@ -1,15 +0,0 @@
import { z } from 'zod';
export const ZSendMailMutationSchema = z.object({
email: z.string().min(1).email(),
name: z.string().min(1).optional(),
firstName: z.string().min(1).optional(),
documentSigningLink: z.string().min(1).optional(),
documentName: z.string().min(1).optional(),
downloadLink: z.string().min(1).optional(),
reviewLink: z.string().min(1).optional(),
numberOfSigners: z.number().int().min(1).optional(),
type: z.enum(['invite', 'signed', 'completed']),
});
export type TSendMailMutationSchema = z.infer<typeof ZSendMailMutationSchema>;
+2 -2
View File
@@ -1,5 +1,5 @@
import { authRouter } from './auth-router/router';
import { mailRouter } from './mail-router/router';
import { documentRouter } from './document-router/router';
import { profileRouter } from './profile-router/router';
import { procedure, router } from './trpc';
@@ -7,7 +7,7 @@ export const appRouter = router({
hello: procedure.query(() => 'Hello, world!'),
auth: authRouter,
profile: profileRouter,
mail: mailRouter,
document: documentRouter,
});
export type AppRouter = typeof appRouter;
+7 -16
View File
@@ -10,22 +10,10 @@ export type CardProps = React.HTMLAttributes<HTMLDivElement> & {
spotlight?: boolean;
gradient?: boolean;
degrees?: number;
lightMode?: boolean;
};
const Card = React.forwardRef<HTMLDivElement, CardProps>(
(
{
className,
children,
gradient = false,
spotlight = false,
degrees = 120,
lightMode = true,
...props
},
ref,
) => {
({ className, children, gradient = false, spotlight = false, degrees = 120, ...props }, ref) => {
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
@@ -46,12 +34,15 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
} as React.CSSProperties
}
className={cn(
'bg-background text-foreground dark:hover:border-documenso group relative rounded-lg border-2 backdrop-blur-[2px]',
'bg-background text-foreground group relative rounded-lg border-2 backdrop-blur-[2px]',
{
'gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.documenso.DEFAULT/50%)_5%,theme(colors.border/80%)_30%)]':
gradient && lightMode,
gradient,
'dark:gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.documenso.DEFAULT/70%)_5%,theme(colors.border/80%)_30%)]':
gradient,
'shadow-[0_0_0_4px_theme(colors.gray.100/70%),0_0_0_1px_theme(colors.gray.100/70%),0_0_0_0.5px_theme(colors.primary.DEFAULT/70%)]':
lightMode,
true,
'dark:shadow-[0]': true,
},
className,
)}
+1 -7
View File
@@ -18,12 +18,6 @@
"NEXT_PUBLIC_SITE_URL",
"NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_NEXT_AUTH_SECRET",
"NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED",
"NEXT_PRIVATE_SENDGRID_API_KEY",
"NEXT_PRIVATE_SMTP_MAIL_HOST",
"NEXT_PRIVATE_SMTP_MAIL_PORT",
"NEXT_PRIVATE_SMTP_MAIL_USER",
"NEXT_PRIVATE_SMTP_MAIL_PASSWORD"
"NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED"
]
}