Compare commits
4 Commits
feat/new-e
...
feat/add-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea85aac4a6 | ||
|
|
07c0b03a99 | ||
|
|
eea09dcfac | ||
|
|
3aea62e898 |
@@ -17,10 +17,3 @@ NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false
|
|||||||
# This is only required for the marketing site
|
# This is only required for the marketing site
|
||||||
NEXT_PRIVATE_REDIS_URL=
|
NEXT_PRIVATE_REDIS_URL=
|
||||||
NEXT_PRIVATE_REDIS_TOKEN=
|
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=
|
|
||||||
@@ -43,7 +43,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<PlausibleProvider>{children}</PlausibleProvider>
|
<PlausibleProvider>
|
||||||
|
{children}
|
||||||
|
</PlausibleProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,12 +21,11 @@
|
|||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.214.0",
|
"lucide-react": "^0.214.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
|
"nanoid": "^4.0.2",
|
||||||
"next": "13.4.1",
|
"next": "13.4.1",
|
||||||
"next-auth": "^4.22.1",
|
"next-auth": "^4.22.1",
|
||||||
"next-plausible": "^3.7.2",
|
"next-plausible": "^3.7.2",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nodemailer": "^6.9.3",
|
|
||||||
"nodemailer-sendgrid": "^1.0.3",
|
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
@@ -34,14 +33,13 @@
|
|||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"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",
|
||||||
"typescript": "5.0.4",
|
"typescript": "5.0.4",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/nodemailer": "^6.4.8",
|
|
||||||
"@types/nodemailer-sendgrid": "^1.0.0",
|
|
||||||
"@types/react": "18.2.6",
|
"@types/react": "18.2.6",
|
||||||
"@types/react-dom": "18.2.4"
|
"@types/react-dom": "18.2.4"
|
||||||
}
|
}
|
||||||
|
|||||||
6
apps/web/process-env.d.ts
vendored
6
apps/web/process-env.d.ts
vendored
@@ -11,11 +11,5 @@ declare namespace NodeJS {
|
|||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||||
|
|
||||||
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: 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: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 346 B |
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 332 B |
@@ -5,6 +5,8 @@ import { ChevronLeft } from 'lucide-react';
|
|||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
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';
|
import { EditDocumentForm } from '~/components/forms/edit-document';
|
||||||
|
|
||||||
@@ -34,6 +36,17 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
redirect('/documents');
|
redirect('/documents');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [recipients, fields] = await Promise.all([
|
||||||
|
await getRecipientsForDocument({
|
||||||
|
documentId,
|
||||||
|
userId: session.id,
|
||||||
|
}),
|
||||||
|
await getFieldsForDocument({
|
||||||
|
documentId,
|
||||||
|
userId: session.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<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">
|
<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}
|
{document.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<EditDocumentForm className="mt-8" document={document} user={session} />
|
<EditDocumentForm
|
||||||
|
className="mt-8"
|
||||||
|
document={document}
|
||||||
|
user={session}
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { Variants, motion } from 'framer-motion';
|
import { Variants, motion } from 'framer-motion';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useTheme } from 'next-themes';
|
|
||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -92,8 +91,6 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { theme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className={cn('flex', className)}
|
className={cn('flex', className)}
|
||||||
@@ -110,7 +107,6 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
|
|||||||
)}
|
)}
|
||||||
gradient={true}
|
gradient={true}
|
||||||
degrees={120}
|
degrees={120}
|
||||||
lightMode={theme === 'light'}
|
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -120,6 +116,7 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
|
|||||||
<motion.div
|
<motion.div
|
||||||
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
|
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
|
||||||
variants={DocumentDropzoneCardLeftVariants}
|
variants={DocumentDropzoneCardLeftVariants}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
||||||
@@ -129,6 +126,7 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
|
|||||||
<motion.div
|
<motion.div
|
||||||
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
|
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
|
||||||
variants={DocumentDropzoneCardCenterVariants}
|
variants={DocumentDropzoneCardCenterVariants}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<Plus
|
<Plus
|
||||||
strokeWidth="2px"
|
strokeWidth="2px"
|
||||||
@@ -139,6 +137,7 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
|
|||||||
<motion.div
|
<motion.div
|
||||||
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
|
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
|
||||||
variants={DocumentDropzoneCardRightVariants}
|
variants={DocumentDropzoneCardRightVariants}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
||||||
|
|||||||
@@ -67,16 +67,6 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
|
|||||||
const pageX = event.clientX - left;
|
const pageX = event.clientX - left;
|
||||||
const pageY = event.clientY - top;
|
const pageY = event.clientY - top;
|
||||||
|
|
||||||
console.log({
|
|
||||||
pageNumber,
|
|
||||||
numPages,
|
|
||||||
originalEvent: event,
|
|
||||||
pageHeight: height,
|
|
||||||
pageWidth: width,
|
|
||||||
pageX,
|
|
||||||
pageY,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (onPageClick) {
|
if (onPageClick) {
|
||||||
onPageClick({
|
onPageClick({
|
||||||
pageNumber,
|
pageNumber,
|
||||||
@@ -137,6 +127,8 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
|
|||||||
<PDFPage
|
<PDFPage
|
||||||
pageNumber={i + 1}
|
pageNumber={i + 1}
|
||||||
width={width}
|
width={width}
|
||||||
|
renderAnnotationLayer={false}
|
||||||
|
renderTextLayer={false}
|
||||||
onClick={(e) => onDocumentPageClick(e, i + 1)}
|
onClick={(e) => onDocumentPageClick(e, i + 1)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
2
apps/web/src/components/(dashboard)/pdf-viewer/types.ts
Normal file
2
apps/web/src/components/(dashboard)/pdf-viewer/types.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const PDF_VIEWER_CONTAINER_SELECTOR = '.react-pdf__Document';
|
||||||
|
export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page';
|
||||||
@@ -41,16 +41,16 @@ export const Callout = () => {
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm" asChild>
|
||||||
<Link
|
<Link
|
||||||
href="https://github.com/documenso/documenso"
|
href="https://github.com/documenso/documenso"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
onClick={() => event('view-github')}
|
onClick={() => event('view-github')}
|
||||||
>
|
>
|
||||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
|
||||||
<Github className="mr-2 h-5 w-5" />
|
<Github className="mr-2 h-5 w-5" />
|
||||||
Star on Github
|
Star on Github
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -114,12 +114,19 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
|
<Button
|
||||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
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" />
|
<Github className="mr-2 h-5 w-5" />
|
||||||
Star on Github
|
Star on Github
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -93,14 +93,15 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
For small teams and individuals who need a simple solution
|
For small teams and individuals who need a simple solution
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<Button className="mt-6 rounded-full text-base">
|
||||||
<Link
|
<Link
|
||||||
href="https://github.com/documenso/documenso"
|
href="https://github.com/documenso/documenso"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="mt-6"
|
|
||||||
onClick={() => event('view-github')}
|
onClick={() => event('view-github')}
|
||||||
>
|
>
|
||||||
<Button className="rounded-full text-base">View on Github</Button>
|
View on Github
|
||||||
</Link>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
<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>
|
<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 { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { FieldError } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
export type FormErrorMessageProps = {
|
export type FormErrorMessageProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
error: FieldError | undefined;
|
error: { message?: string } | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => {
|
export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => {
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useId, useState } from 'react';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { useTheme } from 'next-themes';
|
|
||||||
import { useForm } from 'react-hook-form';
|
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 { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
import { AddFieldsFormPartial } from './edit-document/add-fields';
|
import { AddFieldsFormPartial } from './edit-document/add-fields';
|
||||||
import { AddSignersFormPartial } from './edit-document/add-signers';
|
import { AddSignersFormPartial } from './edit-document/add-signers';
|
||||||
|
import { AddSubjectFormPartial } from './edit-document/add-subject';
|
||||||
import { TEditDocumentFormSchema, ZEditDocumentFormSchema } from './edit-document/types';
|
import { TEditDocumentFormSchema, ZEditDocumentFormSchema } from './edit-document/types';
|
||||||
|
|
||||||
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
|
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
|
||||||
@@ -35,57 +36,143 @@ export type EditDocumentFormProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
user: User;
|
||||||
document: Document;
|
document: Document;
|
||||||
|
recipients: Recipient[];
|
||||||
|
fields: Field[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditDocumentForm = ({ className, document, user: _user }: EditDocumentFormProps) => {
|
export const EditDocumentForm = ({
|
||||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
className,
|
||||||
|
document,
|
||||||
|
recipients,
|
||||||
|
fields,
|
||||||
|
user: _user,
|
||||||
|
}: EditDocumentFormProps) => {
|
||||||
|
const initialId = useId();
|
||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
|
const [nextStepLoading, setNextStepLoading] = useState(false);
|
||||||
|
|
||||||
const {
|
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||||
control,
|
const defaultSigners =
|
||||||
// handleSubmit,
|
recipients.length > 0
|
||||||
watch,
|
? recipients.map((recipient) => ({
|
||||||
formState: { errors, isSubmitting, isValid },
|
nativeId: recipient.id,
|
||||||
} = useForm<TEditDocumentFormSchema>({
|
formId: `${recipient.id}-${recipient.documentId}`,
|
||||||
mode: 'onBlur',
|
name: recipient.name,
|
||||||
defaultValues: {
|
email: recipient.email,
|
||||||
signers: [
|
}))
|
||||||
|
: [
|
||||||
{
|
{
|
||||||
|
formId: initialId,
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
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,
|
||||||
|
watch,
|
||||||
|
trigger,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<TEditDocumentFormSchema>({
|
||||||
|
mode: 'onBlur',
|
||||||
|
defaultValues: {
|
||||||
|
signers: defaultSigners,
|
||||||
|
fields: defaultFields,
|
||||||
|
email: {
|
||||||
|
subject: '',
|
||||||
|
message: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZEditDocumentFormSchema),
|
resolver: zodResolver(ZEditDocumentFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { theme } = useTheme();
|
const signersFormValue = watch('signers');
|
||||||
|
const fieldsFormValue = watch('fields');
|
||||||
|
|
||||||
|
console.log({ state: watch(), errors });
|
||||||
|
|
||||||
const canGoBack = step > 0;
|
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 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 (
|
return (
|
||||||
<div className={cn('grid w-full grid-cols-12 gap-x-8', className)}>
|
<div className={cn('grid w-full grid-cols-12 gap-x-8', className)}>
|
||||||
<Card
|
<Card className="col-span-7 rounded-xl before:rounded-xl" gradient>
|
||||||
className="col-span-7 rounded-xl before:rounded-xl"
|
|
||||||
gradient
|
|
||||||
lightMode={theme === 'light'}
|
|
||||||
>
|
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer document={documentUrl} />
|
<PDFViewer document={documentUrl} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="relative col-span-5">
|
<div className="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">
|
<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 && (
|
{step === 0 && (
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
className="-mx-2 flex-1 overflow-y-hidden px-2"
|
className="-mx-2 flex-1 overflow-y-hidden px-2"
|
||||||
control={control}
|
control={control}
|
||||||
|
watch={watch}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
/>
|
/>
|
||||||
@@ -98,7 +185,16 @@ export const EditDocumentForm = ({ className, document, user: _user }: EditDocum
|
|||||||
watch={watch}
|
watch={watch}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
isSubmitting={isSubmitting}
|
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">
|
<div className="mt-4 flex gap-x-4">
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -127,17 +224,27 @@ export const EditDocumentForm = ({ className, document, user: _user }: EditDocum
|
|||||||
Go Back
|
Go Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{step < MAX_STEP && (
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
className="bg-documenso flex-1"
|
className="bg-documenso flex-1"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={!canGoNext}
|
disabled={!canGoNext}
|
||||||
onClick={onGoNextClick}
|
onClick={onGoNextClick}
|
||||||
>
|
>
|
||||||
|
{nextStepLoading && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === MAX_STEP && (
|
||||||
|
<Button type="submit" className="bg-documenso flex-1" size="lg">
|
||||||
|
Complete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Caveat } from 'next/font/google';
|
import { Caveat } from 'next/font/google';
|
||||||
|
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
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 { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@@ -19,7 +21,10 @@ import {
|
|||||||
} from '@documenso/ui/primitives/command';
|
} from '@documenso/ui/primitives/command';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
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({
|
const fontCaveat = Caveat({
|
||||||
weight: ['500'],
|
weight: ['500'],
|
||||||
@@ -28,30 +33,285 @@ const fontCaveat = Caveat({
|
|||||||
variable: '--font-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 = {
|
export type AddFieldsFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
control: Control<TEditDocumentFormSchema>;
|
control: Control<TEditDocumentFormSchema>;
|
||||||
watch: UseFormWatch<TEditDocumentFormSchema>;
|
watch: UseFormWatch<TEditDocumentFormSchema>;
|
||||||
errors: FieldErrors<TEditDocumentFormSchema>;
|
errors: FieldErrors<TEditDocumentFormSchema>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
theme: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddFieldsFormPartial = ({
|
export const AddFieldsFormPartial = ({
|
||||||
className,
|
className,
|
||||||
control: _control,
|
control: control,
|
||||||
watch,
|
watch,
|
||||||
errors: _errors,
|
errors: _errors,
|
||||||
isSubmitting: _isSubmitting,
|
isSubmitting: _isSubmitting,
|
||||||
theme,
|
|
||||||
}: AddFieldsFormProps) => {
|
}: AddFieldsFormProps) => {
|
||||||
const signers = watch('signers');
|
const signers = watch('signers');
|
||||||
|
const fields = watch('fields');
|
||||||
|
|
||||||
|
const { append, remove, update } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: 'fields',
|
||||||
|
});
|
||||||
|
|
||||||
const [selectedSigner, setSelectedSigner] = useState(() => signers[0]);
|
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 (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<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">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
Add all relevant fields for each recipient.
|
Add all relevant fields for each recipient.
|
||||||
@@ -62,6 +322,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className="bg-background text-muted-foreground justify-between font-normal"
|
className="bg-background text-muted-foreground justify-between font-normal"
|
||||||
@@ -87,6 +348,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
{signers.map((signer, index) => (
|
{signers.map((signer, index) => (
|
||||||
<CommandItem key={index} onSelect={() => setSelectedSigner(signer)}>
|
<CommandItem key={index} onSelect={() => setSelectedSigner(signer)}>
|
||||||
<Check
|
<Check
|
||||||
|
aria-hidden={signer !== selectedSigner}
|
||||||
className={cn('mr-2 h-4 w-4', {
|
className={cn('mr-2 h-4 w-4', {
|
||||||
'opacity-0': signer !== selectedSigner,
|
'opacity-0': signer !== selectedSigner,
|
||||||
'opacity-100': 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="-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">
|
<div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-8">
|
||||||
<button className="group h-full w-full">
|
<button
|
||||||
<Card
|
type="button"
|
||||||
className="group-focus:border-documenso h-full w-full cursor-pointer"
|
className="group h-full w-full"
|
||||||
lightMode={theme === 'light'}
|
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">
|
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
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,
|
fontCaveat.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -128,15 +392,17 @@ export const AddFieldsFormPartial = ({
|
|||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="group h-full w-full">
|
<button
|
||||||
<Card
|
type="button"
|
||||||
className="group-focus:border-documenso h-full w-full cursor-pointer"
|
className="group h-full w-full"
|
||||||
lightMode={theme === 'light'}
|
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">
|
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
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'}
|
{'Email'}
|
||||||
@@ -147,15 +413,17 @@ export const AddFieldsFormPartial = ({
|
|||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="group h-full w-full">
|
<button
|
||||||
<Card
|
type="button"
|
||||||
className="group-focus:border-documenso h-full w-full cursor-pointer"
|
className="group h-full w-full"
|
||||||
lightMode={theme === 'light'}
|
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">
|
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
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'}
|
{'Name'}
|
||||||
@@ -166,15 +434,17 @@ export const AddFieldsFormPartial = ({
|
|||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="group h-full w-full">
|
<button
|
||||||
<Card
|
type="button"
|
||||||
className="group-focus:border-documenso h-full w-full cursor-pointer"
|
className="group h-full w-full"
|
||||||
lightMode={theme === 'light'}
|
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">
|
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
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'}
|
{'Date'}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Plus, Trash } from 'lucide-react';
|
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 { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -16,6 +19,7 @@ import { TEditDocumentFormSchema } from './types';
|
|||||||
export type AddSignersFormProps = {
|
export type AddSignersFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
control: Control<TEditDocumentFormSchema>;
|
control: Control<TEditDocumentFormSchema>;
|
||||||
|
watch: UseFormWatch<TEditDocumentFormSchema>;
|
||||||
errors: FieldErrors<TEditDocumentFormSchema>;
|
errors: FieldErrors<TEditDocumentFormSchema>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
};
|
};
|
||||||
@@ -27,14 +31,49 @@ export const AddSignersFormPartial = ({
|
|||||||
isSubmitting,
|
isSubmitting,
|
||||||
}: AddSignersFormProps) => {
|
}: AddSignersFormProps) => {
|
||||||
const {
|
const {
|
||||||
append,
|
append: appendSigner,
|
||||||
fields: signers,
|
fields: signers,
|
||||||
remove,
|
remove: removeSigner,
|
||||||
} = useFieldArray({
|
} = useFieldArray({
|
||||||
control,
|
control,
|
||||||
name: 'signers',
|
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 (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<div className={cn('flex flex-col', className)}>
|
||||||
<h3 className="text-foreground text-2xl font-semibold">Add Signers</h3>
|
<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" />
|
<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">
|
<div className="flex w-full flex-col gap-y-4">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{signers.map((field, index) => (
|
{signers.map((signer, index) => (
|
||||||
<motion.div key={field.id} className="flex flex-wrap items-end gap-x-4">
|
<motion.div key={signer.formId} className="flex flex-wrap items-end gap-x-4">
|
||||||
<div className="flex-1">
|
<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
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={`signers.${index}.email`}
|
name={`signers.${index}.email`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Input
|
<Input
|
||||||
id={`signer-${index}-email`}
|
id={`signer-${signer.formId}-email`}
|
||||||
type="email"
|
type="email"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -69,17 +112,18 @@ export const AddSignersFormPartial = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label htmlFor={`signer-${index}-name`}>Name</Label>
|
<Label htmlFor={`signer-${signer.formId}-name`}>Name</Label>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={`signers.${index}.name`}
|
name={`signers.${index}.name`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Input
|
<Input
|
||||||
id={`signer-${index}-name`}
|
id={`signer-${signer.formId}-name`}
|
||||||
type="text"
|
type="text"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -89,9 +133,9 @@ export const AddSignersFormPartial = ({
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80"
|
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}
|
disabled={isSubmitting || signers.length === 1}
|
||||||
onClick={() => remove(index)}
|
onClick={() => onRemoveSigner(index)}
|
||||||
>
|
>
|
||||||
<Trash className="h-5 w-5" />
|
<Trash className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -106,17 +150,10 @@ export const AddSignersFormPartial = ({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-2" error={errors.signers} />
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button
|
<Button type="button" disabled={isSubmitting} onClick={() => onAddSigner()}>
|
||||||
type="button"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={() =>
|
|
||||||
append({
|
|
||||||
email: '',
|
|
||||||
name: '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||||
Add Signer
|
Add Signer
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
111
apps/web/src/components/forms/edit-document/add-subject.tsx
Normal file
111
apps/web/src/components/forms/edit-document/add-subject.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
149
apps/web/src/components/forms/edit-document/field-item.tsx
Normal file
149
apps/web/src/components/forms/edit-document/field-item.tsx
Normal file
@@ -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,
|
||||||
|
);
|
||||||
|
};
|
||||||
53
apps/web/src/components/forms/edit-document/provider.tsx
Normal file
53
apps/web/src/components/forms/edit-document/provider.tsx
Normal file
@@ -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 { z } from 'zod';
|
||||||
|
|
||||||
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const ZEditDocumentFormSchema = z.object({
|
export const ZEditDocumentFormSchema = z.object({
|
||||||
signers: z.array(
|
signers: z
|
||||||
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.number().optional(),
|
formId: z.string().min(1),
|
||||||
|
nativeId: z.number().optional(),
|
||||||
email: z.string().min(1).email(),
|
email: z.string().min(1).email(),
|
||||||
name: z.string(),
|
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({
|
||||||
|
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 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',
|
||||||
|
};
|
||||||
|
|||||||
3306
package-lock.json
generated
3306
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -5,17 +5,13 @@
|
|||||||
"dev": "turbo run dev --filter=@documenso/{web,marketing}",
|
"dev": "turbo run dev --filter=@documenso/{web,marketing}",
|
||||||
"start": "cd apps && cd web && next start",
|
"start": "cd apps && cd web && next start",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
"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"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"dotenv-cli": "^7.2.1",
|
"dotenv-cli": "^7.2.1",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^7.32.0",
|
||||||
"eslint-config-custom": "*",
|
"eslint-config-custom": "*",
|
||||||
"npm-run-all": "^4.1.5",
|
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"turbo": "^1.9.3"
|
"turbo": "^1.9.3"
|
||||||
},
|
},
|
||||||
@@ -24,8 +20,5 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
]
|
||||||
"prisma": {
|
|
||||||
"schema": "packages/prisma/schema.prisma"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
@@ -30,7 +30,6 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
const user = await getUserByEmail({ email }).catch(() => null);
|
const user = await getUserByEmail({ email }).catch(() => null);
|
||||||
|
|
||||||
if (!user || !user.password) {
|
if (!user || !user.password) {
|
||||||
console.log('no user');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,18 +10,18 @@
|
|||||||
"universal/",
|
"universal/",
|
||||||
"next-auth/"
|
"next-auth/"
|
||||||
],
|
],
|
||||||
"scripts": {},
|
"scripts": {
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@next-auth/prisma-adapter": "^1.0.6",
|
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"@react-email/components": "^0.0.7",
|
"@next-auth/prisma-adapter": "^1.0.6",
|
||||||
"@react-email/render": "^0.0.7",
|
|
||||||
"@upstash/redis": "^1.20.6",
|
"@upstash/redis": "^1.20.6",
|
||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"nanoid": "^4.0.2",
|
||||||
"next": "13.4.1",
|
"next": "13.4.1",
|
||||||
"next-auth": "^4.22.1",
|
"next-auth": "^4.22.1",
|
||||||
"pdf-lib": "^1.17.1",
|
|
||||||
"stripe": "^12.7.0"
|
"stripe": "^12.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
19
packages/lib/server-only/field/get-fields-for-document.ts
Normal file
19
packages/lib/server-only/field/get-fields-for-document.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
127
packages/lib/server-only/field/set-fields-for-document.ts
Normal file
127
packages/lib/server-only/field/set-fields-for-document.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = ["extendedWhereUnique"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -123,11 +124,15 @@ model Recipient {
|
|||||||
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
Field Field[]
|
Field Field[]
|
||||||
Signature Signature[]
|
Signature Signature[]
|
||||||
|
|
||||||
|
@@unique([documentId, email])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FieldType {
|
enum FieldType {
|
||||||
SIGNATURE
|
SIGNATURE
|
||||||
FREE_SIGNATURE
|
FREE_SIGNATURE
|
||||||
|
NAME
|
||||||
|
EMAIL
|
||||||
DATE
|
DATE
|
||||||
TEXT
|
TEXT
|
||||||
}
|
}
|
||||||
@@ -138,8 +143,10 @@ model Field {
|
|||||||
recipientId Int?
|
recipientId Int?
|
||||||
type FieldType
|
type FieldType
|
||||||
page Int
|
page Int
|
||||||
positionX Int @default(0)
|
positionX Decimal @default(0)
|
||||||
positionY Int @default(0)
|
positionY Decimal @default(0)
|
||||||
|
width Decimal @default(-1)
|
||||||
|
height Decimal @default(-1)
|
||||||
customText String
|
customText String
|
||||||
inserted Boolean
|
inserted Boolean
|
||||||
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@documenso/lib": "*",
|
||||||
|
"@documenso/prisma": "*",
|
||||||
"@tanstack/react-query": "^4.29.5",
|
"@tanstack/react-query": "^4.29.5",
|
||||||
"@trpc/client": "^10.25.1",
|
"@trpc/client": "^10.25.1",
|
||||||
"@trpc/next": "^10.25.1",
|
"@trpc/next": "^10.25.1",
|
||||||
|
|||||||
55
packages/trpc/server/document-router/router.ts
Normal file
55
packages/trpc/server/document-router/router.ts
Normal file
@@ -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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
38
packages/trpc/server/document-router/schema.ts
Normal file
38
packages/trpc/server/document-router/schema.ts
Normal file
@@ -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>;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { authRouter } from './auth-router/router';
|
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 { profileRouter } from './profile-router/router';
|
||||||
import { procedure, router } from './trpc';
|
import { procedure, router } from './trpc';
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ export const appRouter = router({
|
|||||||
hello: procedure.query(() => 'Hello, world!'),
|
hello: procedure.query(() => 'Hello, world!'),
|
||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
profile: profileRouter,
|
profile: profileRouter,
|
||||||
mail: mailRouter,
|
document: documentRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@@ -10,22 +10,10 @@ export type CardProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
spotlight?: boolean;
|
spotlight?: boolean;
|
||||||
gradient?: boolean;
|
gradient?: boolean;
|
||||||
degrees?: number;
|
degrees?: number;
|
||||||
lightMode?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||||
(
|
({ className, children, gradient = false, spotlight = false, degrees = 120, ...props }, ref) => {
|
||||||
{
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
gradient = false,
|
|
||||||
spotlight = false,
|
|
||||||
degrees = 120,
|
|
||||||
lightMode = true,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const mouseX = useMotionValue(0);
|
const mouseX = useMotionValue(0);
|
||||||
const mouseY = useMotionValue(0);
|
const mouseY = useMotionValue(0);
|
||||||
|
|
||||||
@@ -46,12 +34,15 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
className={cn(
|
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-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%)]':
|
'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,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,12 +18,6 @@
|
|||||||
"NEXT_PUBLIC_SITE_URL",
|
"NEXT_PUBLIC_SITE_URL",
|
||||||
"NEXT_PRIVATE_DATABASE_URL",
|
"NEXT_PRIVATE_DATABASE_URL",
|
||||||
"NEXT_PRIVATE_NEXT_AUTH_SECRET",
|
"NEXT_PRIVATE_NEXT_AUTH_SECRET",
|
||||||
"NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED",
|
"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"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user