mirror of
https://github.com/documenso/documenso.git
synced 2026-07-04 10:04:56 +10:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3af26291c9 | |||
| d1bc948f3c | |||
| 2b84636993 |
@@ -21,7 +21,6 @@
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.214.0",
|
||||
"micro": "^10.0.1",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.1",
|
||||
"next-auth": "^4.22.1",
|
||||
"next-plausible": "^3.7.2",
|
||||
@@ -33,7 +32,6 @@
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-pdf": "^7.1.1",
|
||||
"react-rnd": "^10.4.1",
|
||||
"typescript": "5.0.4",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
|
||||
Vendored
+3
@@ -11,5 +11,8 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||
|
||||
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: string;
|
||||
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from '@documenso/ui/primitives/table';
|
||||
|
||||
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||
import { ActionButtons } from '~/components/(dashboard)/table/actions-component';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
@@ -39,15 +38,9 @@ export default async function DashboardPage() {
|
||||
<h1 className="text-4xl font-semibold">Dashboard</h1>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Link href={'/documents?status=COMPLETED'} passHref>
|
||||
<CardMetric icon={FileCheck} title="Completed" value={stats.COMPLETED} />
|
||||
</Link>
|
||||
<Link href={'/documents?status=DRAFT'} passHref>
|
||||
<CardMetric icon={File} title="Drafts" value={stats.DRAFT} />
|
||||
</Link>
|
||||
<Link href={'/documents?status=PENDING'} passHref>
|
||||
<CardMetric icon={Clock} title="Pending" value={stats.PENDING} />
|
||||
</Link>
|
||||
<CardMetric icon={FileCheck} title="Completed" value={stats.COMPLETED} />
|
||||
<CardMetric icon={File} title="Drafts" value={stats.DRAFT} />
|
||||
<CardMetric icon={Clock} title="Pending" value={stats.PENDING} />
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
@@ -62,8 +55,7 @@ export default async function DashboardPage() {
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
<TableHead className="text-right">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -81,17 +73,14 @@ export default async function DashboardPage() {
|
||||
<TableCell>
|
||||
<DocumentStatus status={document.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="text-right">
|
||||
<LocaleDate date={document.created} />
|
||||
</TableCell>
|
||||
<TableCell className="flex cursor-pointer gap-6">
|
||||
<ActionButtons documentId={document.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{results.data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center">
|
||||
<TableCell colSpan={4} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronLeft, Loader } from 'lucide-react';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Documents
|
||||
</Link>
|
||||
<h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||
Loading Document...
|
||||
</h1>
|
||||
<div className="mt-8 grid min-h-[80vh] w-full grid-cols-12 gap-x-8">
|
||||
<div className="dark:bg-background border-documenso col-span-7 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl">
|
||||
<div className="flex min-h-[80vh] flex-col items-center justify-center">
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-background border-documenso col-span-5 rounded-xl border-2 before:rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
|
||||
import { EditDocumentForm } from '~/components/forms/edit-document';
|
||||
|
||||
@@ -36,22 +34,11 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
const [recipients, fields] = await Promise.all([
|
||||
await getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: session.id,
|
||||
}),
|
||||
await getFieldsForDocument({
|
||||
documentId,
|
||||
userId: session.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Documents
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<h1
|
||||
@@ -61,13 +48,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
document={document}
|
||||
user={session}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
/>
|
||||
<EditDocumentForm className="mt-8" document={document} user={session} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Document } from '@documenso/prisma/client';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
|
||||
import { ActionButtons } from '~/components/(dashboard)/table/actions-component';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
@@ -60,11 +59,6 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
accessorKey: 'created',
|
||||
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
|
||||
},
|
||||
{
|
||||
header: 'Actions',
|
||||
accessorKey: 'actions',
|
||||
cell: ({ row }) => <ActionButtons documentId={row.original.id} />,
|
||||
},
|
||||
]}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
|
||||
@@ -110,7 +110,7 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
|
||||
)}
|
||||
gradient={true}
|
||||
degrees={120}
|
||||
|
||||
lightMode={theme === 'light'}
|
||||
{...getRootProps()}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -13,12 +13,7 @@ export type CardMetricProps = {
|
||||
|
||||
export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border bg-background overflow-hidden rounded-lg border shadow shadow-transparent duration-200 hover:shadow-slate-100',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn('border-border bg-background overflow-hidden rounded-lg border', className)}>
|
||||
<div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
|
||||
<div className="flex items-start">
|
||||
{Icon && <Icon className="mr-2 h-4 w-4 text-slate-500" />}
|
||||
|
||||
@@ -67,6 +67,16 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
|
||||
const pageX = event.clientX - left;
|
||||
const pageY = event.clientY - top;
|
||||
|
||||
console.log({
|
||||
pageNumber,
|
||||
numPages,
|
||||
originalEvent: event,
|
||||
pageHeight: height,
|
||||
pageWidth: width,
|
||||
pageX,
|
||||
pageY,
|
||||
});
|
||||
|
||||
if (onPageClick) {
|
||||
onPageClick({
|
||||
pageNumber,
|
||||
@@ -110,10 +120,10 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
|
||||
onLoadSuccess={(d) => onDocumentLoaded(d)}
|
||||
externalLinkTarget="_blank"
|
||||
loading={
|
||||
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
<div className="flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
||||
<Loader className="h-12 w-12 animate-spin text-slate-500" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||
<p className="mt-4 text-slate-500">Loading document...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -127,8 +137,6 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
|
||||
<PDFPage
|
||||
pageNumber={i + 1}
|
||||
width={width}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
onClick={(e) => onDocumentPageClick(e, i + 1)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export const PDF_VIEWER_CONTAINER_SELECTOR = '.react-pdf__Document';
|
||||
export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page';
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Download, Edit, Trash } from 'lucide-react';
|
||||
|
||||
export function ActionButtons({ documentId }: { documentId: number }) {
|
||||
return (
|
||||
<div className="flex cursor-pointer gap-6">
|
||||
<Edit
|
||||
className="text-primary h-5 w-5"
|
||||
onClick={() => {
|
||||
console.log('Edit Document with id: ', documentId);
|
||||
}}
|
||||
/>
|
||||
<Download
|
||||
className="text-primary h-5 w-5"
|
||||
onClick={() => {
|
||||
console.log('Download Document with id: ', documentId);
|
||||
}}
|
||||
/>
|
||||
<Trash
|
||||
className="text-primary h-5 w-5"
|
||||
onClick={() => {
|
||||
console.log('Delete Document with id: ', documentId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,16 +41,16 @@ export const Callout = () => {
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm" asChild>
|
||||
<Link
|
||||
href="https://github.com/documenso/documenso"
|
||||
target="_blank"
|
||||
onClick={() => event('view-github')}
|
||||
>
|
||||
<Link
|
||||
href="https://github.com/documenso/documenso"
|
||||
target="_blank"
|
||||
onClick={() => event('view-github')}
|
||||
>
|
||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
||||
<Github className="mr-2 h-5 w-5" />
|
||||
Star on Github
|
||||
</Link>
|
||||
</Button>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -114,19 +114,12 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href="https://github.com/documenso/documenso"
|
||||
onClick={() => event('view-github')}
|
||||
>
|
||||
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
|
||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
||||
<Github className="mr-2 h-5 w-5" />
|
||||
Star on Github
|
||||
</Link>
|
||||
</Button>
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
|
||||
@@ -93,15 +93,14 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
For small teams and individuals who need a simple solution
|
||||
</p>
|
||||
|
||||
<Button className="mt-6 rounded-full text-base">
|
||||
<Link
|
||||
href="https://github.com/documenso/documenso"
|
||||
target="_blank"
|
||||
onClick={() => event('view-github')}
|
||||
>
|
||||
View on Github
|
||||
</Link>
|
||||
</Button>
|
||||
<Link
|
||||
href="https://github.com/documenso/documenso"
|
||||
target="_blank"
|
||||
className="mt-6"
|
||||
onClick={() => event('view-github')}
|
||||
>
|
||||
<Button className="rounded-full text-base">View on Github</Button>
|
||||
</Link>
|
||||
|
||||
<div className="mt-8 flex w-full flex-col divide-y">
|
||||
<p className="py-4 font-medium text-slate-900">Host your own instance</p>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { FieldError } from 'react-hook-form';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type FormErrorMessageProps = {
|
||||
className?: string;
|
||||
error: { message?: string } | undefined;
|
||||
error: FieldError | undefined;
|
||||
};
|
||||
|
||||
export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => {
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Document, User } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { AddFieldsFormPartial } from './edit-document/add-fields';
|
||||
import { AddSignersFormPartial } from './edit-document/add-signers';
|
||||
import { AddSubjectFormPartial } from './edit-document/add-subject';
|
||||
import { TEditDocumentFormSchema, ZEditDocumentFormSchema } from './edit-document/types';
|
||||
|
||||
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||
</div>
|
||||
@@ -36,143 +35,57 @@ export type EditDocumentFormProps = {
|
||||
className?: string;
|
||||
user: User;
|
||||
document: Document;
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
export const EditDocumentForm = ({
|
||||
className,
|
||||
document,
|
||||
recipients,
|
||||
fields,
|
||||
user: _user,
|
||||
}: EditDocumentFormProps) => {
|
||||
const initialId = useId();
|
||||
export const EditDocumentForm = ({ className, document, user: _user }: EditDocumentFormProps) => {
|
||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [nextStepLoading, setNextStepLoading] = useState(false);
|
||||
|
||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||
const defaultSigners =
|
||||
recipients.length > 0
|
||||
? recipients.map((recipient) => ({
|
||||
nativeId: recipient.id,
|
||||
formId: `${recipient.id}-${recipient.documentId}`,
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
formId: initialId,
|
||||
name: '',
|
||||
email: '',
|
||||
},
|
||||
];
|
||||
|
||||
const defaultFields = fields.map((field) => ({
|
||||
nativeId: field.id,
|
||||
formId: `${field.id}-${field.documentId}`,
|
||||
pageNumber: field.page,
|
||||
type: field.type,
|
||||
pageX: Number(field.positionX),
|
||||
pageY: Number(field.positionY),
|
||||
pageWidth: Number(field.width),
|
||||
pageHeight: Number(field.height),
|
||||
signerEmail: recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
}));
|
||||
|
||||
const { mutateAsync: setRecipientsForDocument } =
|
||||
trpc.document.setRecipientsForDocument.useMutation();
|
||||
|
||||
const { mutateAsync: setFieldsForDocument } = trpc.document.setFieldsForDocument.useMutation();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
// handleSubmit,
|
||||
watch,
|
||||
trigger,
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm<TEditDocumentFormSchema>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
signers: defaultSigners,
|
||||
fields: defaultFields,
|
||||
email: {
|
||||
subject: '',
|
||||
message: '',
|
||||
},
|
||||
signers: [
|
||||
{
|
||||
name: '',
|
||||
email: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
resolver: zodResolver(ZEditDocumentFormSchema),
|
||||
});
|
||||
|
||||
const signersFormValue = watch('signers');
|
||||
const fieldsFormValue = watch('fields');
|
||||
|
||||
console.log({ state: watch(), errors });
|
||||
const { theme } = useTheme();
|
||||
|
||||
const canGoBack = step > 0;
|
||||
const canGoNext = step < MAX_STEP;
|
||||
const canGoNext = isValid && step < MAX_STEP;
|
||||
|
||||
const onGoBackClick = () => setStep((prev) => Math.max(0, 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);
|
||||
};
|
||||
const onGoNextClick = () => setStep((prev) => Math.min(MAX_STEP, prev + 1));
|
||||
|
||||
return (
|
||||
<div className={cn('grid w-full grid-cols-12 gap-x-8', className)}>
|
||||
<Card className="col-span-7 rounded-xl before:rounded-xl" gradient>
|
||||
<Card
|
||||
className="col-span-7 rounded-xl before:rounded-xl"
|
||||
gradient
|
||||
lightMode={theme === 'light'}
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer document={documentUrl} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="col-span-5">
|
||||
<form
|
||||
className="dark:bg-background border-border sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border bg-[hsl(var(--widget))] px-4 py-6"
|
||||
onSubmit={handleSubmit(console.log)}
|
||||
>
|
||||
<div className="relative col-span-5">
|
||||
<div className="dark:bg-background border-border sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border bg-[hsl(var(--widget))] px-4 py-6">
|
||||
{step === 0 && (
|
||||
<AddSignersFormPartial
|
||||
className="-mx-2 flex-1 overflow-y-hidden px-2"
|
||||
control={control}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
@@ -185,16 +98,7 @@ export const EditDocumentForm = ({
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<AddSubjectFormPartial
|
||||
className="-mx-2 flex-1 overflow-y-hidden px-2"
|
||||
control={control}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
isSubmitting={isSubmitting}
|
||||
theme={theme || 'dark'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -214,7 +118,6 @@ export const EditDocumentForm = ({
|
||||
|
||||
<div className="mt-4 flex gap-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
@@ -224,27 +127,17 @@ export const EditDocumentForm = ({
|
||||
Go Back
|
||||
</Button>
|
||||
|
||||
{step < MAX_STEP && (
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-documenso flex-1"
|
||||
size="lg"
|
||||
disabled={!canGoNext}
|
||||
onClick={onGoNextClick}
|
||||
>
|
||||
{nextStepLoading && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
||||
Continue
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step === MAX_STEP && (
|
||||
<Button type="submit" className="bg-documenso flex-1" size="lg">
|
||||
Complete
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="bg-documenso flex-1"
|
||||
size="lg"
|
||||
disabled={!canGoNext}
|
||||
onClick={onGoNextClick}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Caveat } from 'next/font/google';
|
||||
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Control, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form';
|
||||
import { Control, FieldErrors, UseFormWatch } from 'react-hook-form';
|
||||
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@@ -21,10 +19,7 @@ import {
|
||||
} from '@documenso/ui/primitives/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
|
||||
|
||||
import { FieldItem } from './field-item';
|
||||
import { FRIENDLY_FIELD_TYPE, TEditDocumentFormSchema } from './types';
|
||||
import { TEditDocumentFormSchema } from './types';
|
||||
|
||||
const fontCaveat = Caveat({
|
||||
weight: ['500'],
|
||||
@@ -33,285 +28,30 @@ const fontCaveat = Caveat({
|
||||
variable: '--font-caveat',
|
||||
});
|
||||
|
||||
const DEFAULT_HEIGHT_PERCENT = 5;
|
||||
const DEFAULT_WIDTH_PERCENT = 15;
|
||||
|
||||
const MIN_HEIGHT_PX = 60;
|
||||
const MIN_WIDTH_PX = 200;
|
||||
|
||||
export type AddFieldsFormProps = {
|
||||
className?: string;
|
||||
control: Control<TEditDocumentFormSchema>;
|
||||
watch: UseFormWatch<TEditDocumentFormSchema>;
|
||||
errors: FieldErrors<TEditDocumentFormSchema>;
|
||||
isSubmitting: boolean;
|
||||
theme: string;
|
||||
};
|
||||
|
||||
export const AddFieldsFormPartial = ({
|
||||
className,
|
||||
control: control,
|
||||
control: _control,
|
||||
watch,
|
||||
errors: _errors,
|
||||
isSubmitting: _isSubmitting,
|
||||
theme,
|
||||
}: AddFieldsFormProps) => {
|
||||
const signers = watch('signers');
|
||||
const fields = watch('fields');
|
||||
|
||||
const { append, remove, update } = useFieldArray({
|
||||
control,
|
||||
name: 'fields',
|
||||
});
|
||||
|
||||
const [selectedSigner, setSelectedSigner] = useState(() => signers[0]);
|
||||
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const fieldBounds = useRef({
|
||||
height: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
const isWithinPageBounds = useCallback((event: MouseEvent) => {
|
||||
if (!(event.target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
const $page =
|
||||
target.closest(PDF_VIEWER_PAGE_SELECTOR) ?? target.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (!$page) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { top, left, height, width } = $page.getBoundingClientRect();
|
||||
|
||||
if (event.clientY > top + height || event.clientY < top) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.clientX > left + width || event.clientX < left) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!isWithinPageBounds(event)) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setVisible(true);
|
||||
setCoords({
|
||||
x: event.clientX - fieldBounds.current.width / 2,
|
||||
y: event.clientY - fieldBounds.current.height / 2,
|
||||
});
|
||||
},
|
||||
[isWithinPageBounds],
|
||||
);
|
||||
|
||||
const onMouseClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!selectedField) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(event.target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
|
||||
const $page =
|
||||
target.closest<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR) ??
|
||||
target.querySelector<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (!$page || !isWithinPageBounds(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height, width } = $page.getBoundingClientRect();
|
||||
|
||||
const top = $page.offsetTop;
|
||||
const left = $page.offsetLeft;
|
||||
|
||||
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||
|
||||
// Calculate x and y as a percentage of the page width and height
|
||||
let pageX = ((event.pageX - left) / width) * 100;
|
||||
let pageY = ((event.pageY - top) / height) * 100;
|
||||
|
||||
// Get the bounds as a percentage of the page width and height
|
||||
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
|
||||
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
|
||||
|
||||
// And center it based on the bounds
|
||||
pageX -= fieldPageWidth / 2;
|
||||
pageY -= fieldPageHeight / 2;
|
||||
|
||||
append({
|
||||
formId: nanoid(12),
|
||||
type: selectedField,
|
||||
pageNumber,
|
||||
pageX,
|
||||
pageY,
|
||||
pageWidth: fieldPageWidth,
|
||||
pageHeight: fieldPageHeight,
|
||||
signerEmail: selectedSigner.email,
|
||||
});
|
||||
|
||||
setVisible(false);
|
||||
setSelectedField(null);
|
||||
},
|
||||
[append, isWithinPageBounds, selectedField, selectedSigner.email],
|
||||
);
|
||||
|
||||
const onFieldResize = useCallback(
|
||||
(node: HTMLElement, index: number) => {
|
||||
const field = fields[index];
|
||||
|
||||
const $page = document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height: pageHeight, width: pageWidth } = $page.getBoundingClientRect();
|
||||
|
||||
const pageTop = $page.offsetTop;
|
||||
const pageLeft = $page.offsetLeft;
|
||||
|
||||
let { top: nodeTop, left: nodeLeft } = node.getBoundingClientRect();
|
||||
const { height, width } = node.getBoundingClientRect();
|
||||
|
||||
nodeTop += window.scrollY;
|
||||
nodeLeft += window.scrollX;
|
||||
|
||||
// Calculate width and height as a percentage of the page width and height
|
||||
const newWidth = (width / pageWidth) * 100;
|
||||
const newHeight = (height / pageHeight) * 100;
|
||||
|
||||
// Calculate the new position as a percentage of the page width and height
|
||||
const newX = ((nodeLeft - pageLeft) / pageWidth) * 100;
|
||||
const newY = ((nodeTop - pageTop) / pageHeight) * 100;
|
||||
|
||||
update(index, {
|
||||
...field,
|
||||
pageX: newX,
|
||||
pageY: newY,
|
||||
pageWidth: newWidth,
|
||||
pageHeight: newHeight,
|
||||
});
|
||||
},
|
||||
[fields, update],
|
||||
);
|
||||
|
||||
const onFieldMove = useCallback(
|
||||
(node: HTMLElement, index: number) => {
|
||||
const field = fields[index];
|
||||
|
||||
const $page = document.querySelector(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||
);
|
||||
|
||||
if (!$page || !($page instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height: pageHeight, width: pageWidth } = $page.getBoundingClientRect();
|
||||
|
||||
const pageTop = $page.offsetTop;
|
||||
const pageLeft = $page.offsetLeft;
|
||||
|
||||
let { top: nodeTop, left: nodeLeft } = node.getBoundingClientRect();
|
||||
|
||||
nodeTop += window.scrollY;
|
||||
nodeLeft += window.scrollX;
|
||||
|
||||
// Calculate the new position as a percentage of the page width and height
|
||||
const newX = ((nodeLeft - pageLeft) / pageWidth) * 100;
|
||||
const newY = ((nodeTop - pageTop) / pageHeight) * 100;
|
||||
|
||||
update(index, {
|
||||
...field,
|
||||
pageX: newX,
|
||||
pageY: newY,
|
||||
});
|
||||
},
|
||||
[fields, update],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedField) {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('click', onMouseClick);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('click', onMouseClick);
|
||||
};
|
||||
}, [onMouseClick, onMouseMove, selectedField]);
|
||||
|
||||
useEffect(() => {
|
||||
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height, width } = $page.getBoundingClientRect();
|
||||
|
||||
fieldBounds.current = {
|
||||
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
|
||||
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
{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>
|
||||
<h3 className="text-2xl font-semibold">Edit Document</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Add all relevant fields for each recipient.
|
||||
@@ -322,7 +62,6 @@ export const AddFieldsFormPartial = ({
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="bg-background text-muted-foreground justify-between font-normal"
|
||||
@@ -348,7 +87,6 @@ export const AddFieldsFormPartial = ({
|
||||
{signers.map((signer, index) => (
|
||||
<CommandItem key={index} onSelect={() => setSelectedSigner(signer)}>
|
||||
<Check
|
||||
aria-hidden={signer !== selectedSigner}
|
||||
className={cn('mr-2 h-4 w-4', {
|
||||
'opacity-0': signer !== selectedSigner,
|
||||
'opacity-100': signer === selectedSigner,
|
||||
@@ -370,17 +108,15 @@ export const AddFieldsFormPartial = ({
|
||||
|
||||
<div className="-mx-2 mt-8 flex-1 overflow-y-scroll px-2">
|
||||
<div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-8">
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.SIGNATURE)}
|
||||
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
|
||||
<button className="group h-full w-full">
|
||||
<Card
|
||||
className="group-focus:border-documenso h-full w-full cursor-pointer"
|
||||
lightMode={theme === 'light'}
|
||||
>
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-3xl font-medium',
|
||||
'text-muted-foreground group-focus:text-foreground text-3xl font-medium',
|
||||
fontCaveat.className,
|
||||
)}
|
||||
>
|
||||
@@ -392,17 +128,15 @@ export const AddFieldsFormPartial = ({
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.EMAIL)}
|
||||
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
|
||||
<button className="group h-full w-full">
|
||||
<Card
|
||||
className="group-focus:border-documenso h-full w-full cursor-pointer"
|
||||
lightMode={theme === 'light'}
|
||||
>
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
'text-muted-foreground group-focus:text-foreground text-xl font-medium',
|
||||
)}
|
||||
>
|
||||
{'Email'}
|
||||
@@ -413,17 +147,15 @@ export const AddFieldsFormPartial = ({
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.NAME)}
|
||||
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
|
||||
<button className="group h-full w-full">
|
||||
<Card
|
||||
className="group-focus:border-documenso h-full w-full cursor-pointer"
|
||||
lightMode={theme === 'light'}
|
||||
>
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
'text-muted-foreground group-focus:text-foreground text-xl font-medium',
|
||||
)}
|
||||
>
|
||||
{'Name'}
|
||||
@@ -434,17 +166,15 @@ export const AddFieldsFormPartial = ({
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.DATE)}
|
||||
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
|
||||
<button className="group h-full w-full">
|
||||
<Card
|
||||
className="group-focus:border-documenso h-full w-full cursor-pointer"
|
||||
lightMode={theme === 'light'}
|
||||
>
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
'text-muted-foreground group-focus:text-foreground text-xl font-medium',
|
||||
)}
|
||||
>
|
||||
{'Date'}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Plus, Trash } from 'lucide-react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Control, Controller, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form';
|
||||
import { Control, Controller, FieldErrors, useFieldArray } from 'react-hook-form';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -19,7 +16,6 @@ import { TEditDocumentFormSchema } from './types';
|
||||
export type AddSignersFormProps = {
|
||||
className?: string;
|
||||
control: Control<TEditDocumentFormSchema>;
|
||||
watch: UseFormWatch<TEditDocumentFormSchema>;
|
||||
errors: FieldErrors<TEditDocumentFormSchema>;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
@@ -31,49 +27,14 @@ export const AddSignersFormPartial = ({
|
||||
isSubmitting,
|
||||
}: AddSignersFormProps) => {
|
||||
const {
|
||||
append: appendSigner,
|
||||
append,
|
||||
fields: signers,
|
||||
remove: removeSigner,
|
||||
remove,
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: 'signers',
|
||||
});
|
||||
|
||||
const { remove: removeField, fields: fields } = useFieldArray({
|
||||
name: 'fields',
|
||||
control,
|
||||
});
|
||||
|
||||
const onAddSigner = () => {
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
name: '',
|
||||
email: '',
|
||||
});
|
||||
};
|
||||
|
||||
const onRemoveSigner = (index: number) => {
|
||||
const signer = signers[index];
|
||||
|
||||
removeSigner(index);
|
||||
|
||||
const fieldsToRemove: number[] = [];
|
||||
|
||||
fields.forEach((field, fieldIndex) => {
|
||||
if (field.signerEmail === signer.email) {
|
||||
fieldsToRemove.push(fieldIndex);
|
||||
}
|
||||
});
|
||||
|
||||
removeField(fieldsToRemove);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
|
||||
onAddSigner();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<h3 className="text-foreground text-2xl font-semibold">Add Signers</h3>
|
||||
@@ -84,27 +45,23 @@ export const AddSignersFormPartial = ({
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
<div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">
|
||||
<div className="-mx-2 flex flex-1 flex-col overflow-y-scroll px-2">
|
||||
<div className="flex w-full flex-col gap-y-4">
|
||||
<AnimatePresence>
|
||||
{signers.map((signer, index) => (
|
||||
<motion.div key={signer.formId} className="flex flex-wrap items-end gap-x-4">
|
||||
{signers.map((field, index) => (
|
||||
<motion.div key={field.id} className="flex flex-wrap items-end gap-x-4">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor={`signer-${signer.formId}-email`}>
|
||||
Email
|
||||
<span className="text-destructive ml-1 inline-block font-medium">*</span>
|
||||
</Label>
|
||||
<Label htmlFor={`signer-${index}-email`}>Email</Label>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name={`signers.${index}.email`}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id={`signer-${signer.formId}-email`}
|
||||
id={`signer-${index}-email`}
|
||||
type="email"
|
||||
className="bg-background mt-2"
|
||||
disabled={isSubmitting}
|
||||
onKeyDown={onKeyDown}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
@@ -112,18 +69,17 @@ export const AddSignersFormPartial = ({
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Label htmlFor={`signer-${signer.formId}-name`}>Name</Label>
|
||||
<Label htmlFor={`signer-${index}-name`}>Name</Label>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name={`signers.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id={`signer-${signer.formId}-name`}
|
||||
id={`signer-${index}-name`}
|
||||
type="text"
|
||||
className="bg-background mt-2"
|
||||
disabled={isSubmitting}
|
||||
onKeyDown={onKeyDown}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
@@ -133,9 +89,9 @@ export const AddSignersFormPartial = ({
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSubmitting || signers.length === 1}
|
||||
onClick={() => onRemoveSigner(index)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -150,10 +106,17 @@ export const AddSignersFormPartial = ({
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-2" error={errors.signers} />
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="button" disabled={isSubmitting} onClick={() => onAddSigner()}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={() =>
|
||||
append({
|
||||
email: '',
|
||||
name: '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Signer
|
||||
</Button>
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
@@ -1,149 +0,0 @@
|
||||
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,
|
||||
);
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
import React, { createContext, useRef } from 'react';
|
||||
|
||||
import { OnPDFViewerPageClick } from '~/components/(dashboard)/pdf-viewer/pdf-viewer';
|
||||
|
||||
type EditFormContextValue = {
|
||||
firePageClickEvent: OnPDFViewerPageClick;
|
||||
registerPageClickHandler: (_handler: OnPDFViewerPageClick) => void;
|
||||
unregisterPageClickHandler: (_handler: OnPDFViewerPageClick) => void;
|
||||
} | null;
|
||||
|
||||
const EditFormContext = createContext<EditFormContextValue>(null);
|
||||
|
||||
export type EditFormProviderProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const useEditForm = () => {
|
||||
const context = React.useContext(EditFormContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useEditForm must be used within a EditFormProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export const EditFormProvider = ({ children }: EditFormProviderProps) => {
|
||||
const handlers = useRef(new Set<OnPDFViewerPageClick>());
|
||||
|
||||
const firePageClickEvent: OnPDFViewerPageClick = (event) => {
|
||||
handlers.current.forEach((handler) => handler(event));
|
||||
};
|
||||
|
||||
const registerPageClickHandler = (handler: OnPDFViewerPageClick) => {
|
||||
handlers.current.add(handler);
|
||||
};
|
||||
|
||||
const unregisterPageClickHandler = (handler: OnPDFViewerPageClick) => {
|
||||
handlers.current.delete(handler);
|
||||
};
|
||||
|
||||
return (
|
||||
<EditFormContext.Provider
|
||||
value={{
|
||||
firePageClickEvent,
|
||||
registerPageClickHandler,
|
||||
unregisterPageClickHandler,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EditFormContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export const ZEditDocumentFormSchema = z.object({
|
||||
signers: z
|
||||
.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
.refine((signers) => {
|
||||
const emails = signers.map((signer) => signer.email);
|
||||
return new Set(emails).size === emails.length;
|
||||
}, 'Signers must have unique emails'),
|
||||
|
||||
fields: z.array(
|
||||
signers: 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),
|
||||
id: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
|
||||
email: z.object({
|
||||
subject: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TEditDocumentFormSchema = z.infer<typeof ZEditDocumentFormSchema>;
|
||||
|
||||
export const FRIENDLY_FIELD_TYPE: Record<FieldType, string> = {
|
||||
[FieldType.SIGNATURE]: 'Signature',
|
||||
[FieldType.FREE_SIGNATURE]: 'Free Signature',
|
||||
[FieldType.TEXT]: 'Text',
|
||||
[FieldType.DATE]: 'Date',
|
||||
[FieldType.EMAIL]: 'Email',
|
||||
[FieldType.NAME]: 'Name',
|
||||
};
|
||||
|
||||
@@ -61,8 +61,8 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
|
||||
const onSignInWithGoogleClick = async () => {
|
||||
try {
|
||||
// await signIn('google', { callbackUrl: '/dashboard' });
|
||||
throw new Error('Not implemented');
|
||||
await signIn('google', { callbackUrl: '/dashboard' });
|
||||
// throw new Error('Not implemented');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
|
||||
Generated
+7
-6674
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -19,7 +19,6 @@
|
||||
"packageManager": "npm@8.19.2",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*",
|
||||
"packages/email/.react-email"
|
||||
"packages/*"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
.react-email/
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
declare module '@documenso/tailwind-config';
|
||||
@@ -1 +0,0 @@
|
||||
export { render, renderAsync } from '@react-email/components';
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "@documenso/email",
|
||||
"version": "1.0.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"templates/"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "email dev --port 3002 --dir templates"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/tsconfig": "*",
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/ui": "*",
|
||||
"@react-email/components": "^0.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react-email": "^1.9.4"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 784 B |
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 708 B |
@@ -1,11 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const baseConfig = require('@documenso/tailwind-config');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
content: [
|
||||
`templates/**/*.{ts,tsx}`,
|
||||
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
|
||||
],
|
||||
};
|
||||
@@ -1,129 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
interface DocumentCompletedEmailTemplateProps {
|
||||
downloadLink?: string;
|
||||
reviewLink?: string;
|
||||
documentName?: string;
|
||||
assetBaseUrl?: string;
|
||||
}
|
||||
|
||||
export const DocumentCompletedEmailTemplate = ({
|
||||
downloadLink = 'https://documenso.com',
|
||||
reviewLink = 'https://documenso.com',
|
||||
documentName = 'Open Source Pledge.pdf',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: DocumentCompletedEmailTemplateProps) => {
|
||||
const previewText = `Completed Document`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="h-6" />
|
||||
|
||||
<Section className="mt-4 flex-row items-center justify-center">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Img
|
||||
className="h-42"
|
||||
src={getAssetUrl('/static/document.png')}
|
||||
alt="Documenso"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
|
||||
<Img
|
||||
src={getAssetUrl('/static/completed.png')}
|
||||
className="-mb-0.5 mr-2 inline h-7 w-7"
|
||||
/>
|
||||
Completed
|
||||
</Text>
|
||||
|
||||
<Text className="text-primary mb-0 text-center text-lg font-semibold">
|
||||
“{documentName}” was signed by all signers
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
Continue by downloading or reviewing the document.
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href={reviewLink}
|
||||
>
|
||||
<Img
|
||||
src={getAssetUrl('/static/review.png')}
|
||||
className="-mb-1 mr-2 inline h-5 w-5"
|
||||
/>
|
||||
Review
|
||||
</Button>
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href={downloadLink}
|
||||
>
|
||||
<Img
|
||||
src={getAssetUrl('/static/download.png')}
|
||||
className="-mb-1 mr-2 inline h-5 w-5"
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<Section>
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
This document was sent using{' '}
|
||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
||||
Documenso.
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
<Text className="my-8 text-sm text-slate-400">
|
||||
Documenso
|
||||
<br />
|
||||
2261 Market Street, #5211, San Francisco, CA 94114, USA
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentCompletedEmailTemplate;
|
||||
@@ -1,127 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
interface DocumentInviteEmailTemplateProps {
|
||||
inviterName?: string;
|
||||
inviterEmail?: string;
|
||||
documentName?: string;
|
||||
signDocumentLink?: string;
|
||||
assetBaseUrl?: string;
|
||||
}
|
||||
|
||||
export const DocumentInviteEmailTemplate = ({
|
||||
inviterName = 'Lucas Smith',
|
||||
inviterEmail = 'lucas@documenso.com',
|
||||
documentName = 'Open Source Pledge.pdf',
|
||||
signDocumentLink = 'https://documenso.com',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: DocumentInviteEmailTemplateProps) => {
|
||||
const previewText = `Completed Document`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="h-6" />
|
||||
|
||||
<Section className="mt-4 flex-row items-center justify-center">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Img
|
||||
className="h-42"
|
||||
src={getAssetUrl('/static/document.png')}
|
||||
alt="Documenso"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
{inviterName} has invited you to sign "{documentName}"
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
Continue by signing the document.
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={signDocumentLink}
|
||||
>
|
||||
Sign Document
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-auto mt-12 max-w-xl">
|
||||
<Section>
|
||||
<Text className="my-4 text-base font-semibold">
|
||||
{inviterName}{' '}
|
||||
<Link className="font-normal text-slate-400" href="mailto:{inviterEmail}">
|
||||
({inviterEmail})
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
<Text className="mt-2 text-base text-slate-400">
|
||||
{inviterName} has invited you to sign the document "{documentName}".
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<Section>
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
This document was sent using{' '}
|
||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
||||
Documenso.
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
<Text className="my-8 text-sm text-slate-400">
|
||||
Documenso
|
||||
<br />
|
||||
2261 Market Street, #5211, San Francisco, CA 94114, USA
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentInviteEmailTemplate;
|
||||
@@ -1,104 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
interface DocumentPendingEmailTemplateProps {
|
||||
documentName?: string;
|
||||
assetBaseUrl?: string;
|
||||
}
|
||||
|
||||
export const DocumentPendingEmailTemplate = ({
|
||||
documentName = 'Open Source Pledge.pdf',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: DocumentPendingEmailTemplateProps) => {
|
||||
const previewText = `Pending Document`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="h-6" />
|
||||
|
||||
<Section className="mt-4 flex-row items-center justify-center">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Img
|
||||
className="h-42"
|
||||
src={getAssetUrl('/static/document.png')}
|
||||
alt="Documenso"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-blue-500">
|
||||
<Img
|
||||
src={getAssetUrl('/static/clock.png')}
|
||||
className="-mb-0.5 mr-2 inline h-7 w-7"
|
||||
/>
|
||||
Waiting for others
|
||||
</Text>
|
||||
|
||||
<Text className="text-primary mb-0 text-center text-lg font-semibold">
|
||||
“{documentName}” has been signed
|
||||
</Text>
|
||||
|
||||
<Text className="mx-auto mb-6 mt-1 max-w-[80%] text-center text-base text-slate-400">
|
||||
We're still waiting for other signers to sign this document.
|
||||
<br />
|
||||
We'll notify you as soon as it's ready.
|
||||
</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<Section>
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
This document was sent using{' '}
|
||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
||||
Documenso.
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
<Text className="my-8 text-sm text-slate-400">
|
||||
Documenso
|
||||
<br />
|
||||
2261 Market Street, #5211, San Francisco, CA 94114, USA
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentPendingEmailTemplate;
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "@documenso/tsconfig/react-library.json",
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
||||
import { compare } from 'bcrypt';
|
||||
import { AuthOptions, User } from 'next-auth';
|
||||
import { AuthOptions, Session, User } from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import GoogleProvider from 'next-auth/providers/google';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@@ -40,19 +41,62 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(user.id) as any,
|
||||
id: String(user.id),
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: '',
|
||||
} satisfies User;
|
||||
},
|
||||
}),
|
||||
GoogleProvider({
|
||||
clientId: process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID ?? '',
|
||||
clientSecret: process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET ?? '',
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub as any,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.picture,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
// callbacks: {
|
||||
// jwt: async ({ token, user: _user }) => {
|
||||
// return {
|
||||
// ...token,
|
||||
// };
|
||||
// },
|
||||
// },
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
const dbUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: token.email as string,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbUser) {
|
||||
if (user) {
|
||||
token.id = user?.id;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
return {
|
||||
id: dbUser.id,
|
||||
name: dbUser.name,
|
||||
email: dbUser.email,
|
||||
};
|
||||
},
|
||||
async session({ token, session }) {
|
||||
if (token) {
|
||||
const documensoSession = {
|
||||
...session,
|
||||
user: {
|
||||
id: Number(token.id),
|
||||
name: token.name,
|
||||
email: token.email,
|
||||
image: token.image,
|
||||
},
|
||||
} as Session;
|
||||
|
||||
return documensoSession;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"@upstash/redis": "^1.20.6",
|
||||
"bcrypt": "^5.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.1",
|
||||
"next-auth": "^4.22.1",
|
||||
"stripe": "^12.7.0"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
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,22 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
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;
|
||||
};
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
import type { User as PrismaUser } from '@prisma/client';
|
||||
import type { DefaultUser } from 'next-auth';
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface User extends Omit<DefaultUser, 'id' | 'image'> {
|
||||
id: PrismaUser['id'];
|
||||
email?: PrismaUser['email'];
|
||||
name?: PrismaUser['name'];
|
||||
emailVerified?: PrismaUser['emailVerified'];
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT {
|
||||
id: string | number;
|
||||
name?: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "FieldType" ADD VALUE 'NAME';
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "FieldType" ADD VALUE 'EMAIL';
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Field" ADD COLUMN "height" INTEGER NOT NULL DEFAULT -1,
|
||||
ADD COLUMN "width" INTEGER NOT NULL DEFAULT -1;
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
/*
|
||||
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");
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
-- 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,6 +1,5 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["extendedWhereUnique", "jsonProtocol"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -124,15 +123,11 @@ model Recipient {
|
||||
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
Field Field[]
|
||||
Signature Signature[]
|
||||
|
||||
@@unique([documentId, email])
|
||||
}
|
||||
|
||||
enum FieldType {
|
||||
SIGNATURE
|
||||
FREE_SIGNATURE
|
||||
NAME
|
||||
EMAIL
|
||||
DATE
|
||||
TEXT
|
||||
}
|
||||
@@ -143,10 +138,8 @@ model Field {
|
||||
recipientId Int?
|
||||
type FieldType
|
||||
page Int
|
||||
positionX Decimal @default(0)
|
||||
positionY Decimal @default(0)
|
||||
width Decimal @default(-1)
|
||||
height Decimal @default(-1)
|
||||
positionX Int @default(0)
|
||||
positionY Int @default(0)
|
||||
customText String
|
||||
inserted Boolean
|
||||
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
"scripts": {
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"@trpc/client": "^10.25.1",
|
||||
"@trpc/next": "^10.25.1",
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
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,5 +1,4 @@
|
||||
import { authRouter } from './auth-router/router';
|
||||
import { documentRouter } from './document-router/router';
|
||||
import { profileRouter } from './profile-router/router';
|
||||
import { procedure, router } from './trpc';
|
||||
|
||||
@@ -7,7 +6,6 @@ export const appRouter = router({
|
||||
hello: procedure.query(() => 'Hello, world!'),
|
||||
auth: authRouter,
|
||||
profile: profileRouter,
|
||||
document: documentRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -10,10 +10,22 @@ export type CardProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
spotlight?: boolean;
|
||||
gradient?: boolean;
|
||||
degrees?: number;
|
||||
lightMode?: boolean;
|
||||
};
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, children, gradient = false, spotlight = false, degrees = 120, ...props }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
gradient = false,
|
||||
spotlight = false,
|
||||
degrees = 120,
|
||||
lightMode = true,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const mouseX = useMotionValue(0);
|
||||
const mouseY = useMotionValue(0);
|
||||
|
||||
@@ -34,15 +46,12 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
'bg-background text-foreground group relative rounded-lg border-2 backdrop-blur-[2px]',
|
||||
'bg-background text-foreground dark:hover:border-documenso 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,
|
||||
'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,
|
||||
gradient && lightMode,
|
||||
'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%)]':
|
||||
true,
|
||||
'dark:shadow-[0]': true,
|
||||
lightMode,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
|
||||
+3
-1
@@ -18,6 +18,8 @@
|
||||
"NEXT_PUBLIC_SITE_URL",
|
||||
"NEXT_PRIVATE_DATABASE_URL",
|
||||
"NEXT_PRIVATE_NEXT_AUTH_SECRET",
|
||||
"NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED"
|
||||
"NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED",
|
||||
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
||||
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user