feat: use server-actions for authoring flow
This change actually makes the authoring flow work for the most part by tying in emailing and more. We have also done a number of quality of life updates to simplify the codebase overall making it easier to continue work on the refresh.
This commit is contained in:
43
.env.example
43
.env.example
@@ -1,19 +1,56 @@
|
|||||||
|
# [[AUTH]]
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
NEXTAUTH_SECRET="secret"
|
NEXTAUTH_SECRET="secret"
|
||||||
|
|
||||||
|
# [[APP]]
|
||||||
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# [[DATABASE]]
|
||||||
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||||
|
|
||||||
|
# [[SMTP]]
|
||||||
|
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
||||||
|
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
|
||||||
|
# OPTIONAL: Defines the host to use for sending emails.
|
||||||
|
NEXT_PRIVATE_SMTP_HOST="127.0.0.1"
|
||||||
|
# OPTIONAL: Defines the port to use for sending emails.
|
||||||
|
NEXT_PRIVATE_SMTP_PORT=2500
|
||||||
|
# OPTIONAL: Defines the username to use with the SMTP server.
|
||||||
|
NEXT_PRIVATE_SMTP_USERNAME="documenso"
|
||||||
|
# OPTIONAL: Defines the password to use with the SMTP server.
|
||||||
|
NEXT_PRIVATE_SMTP_PASSWORD="password"
|
||||||
|
# OPTIONAL: Defines the API key user to use with the SMTP server.
|
||||||
|
NEXT_PRIVATE_SMTP_APIKEY_USER=
|
||||||
|
# OPTIONAL: Defines the API key to use with the SMTP server.
|
||||||
|
NEXT_PRIVATE_SMTP_APIKEY=
|
||||||
|
# OPTIONAL: Defines whether to force the use of TLS.
|
||||||
|
NEXT_PRIVATE_SMTP_SECURE=
|
||||||
|
# REQUIRED: Defines the sender name to use for the from address.
|
||||||
|
NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso"
|
||||||
|
# REQUIRED: Defines the email address to use as the from address.
|
||||||
|
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
|
||||||
|
# OPTIONAL: The API key to use for the MailChannels proxy endpoint.
|
||||||
|
NEXT_PRIVATE_MAILCHANNELS_API_KEY=
|
||||||
|
# OPTIONAL: The endpoint to use for the MailChannels API if using a proxy.
|
||||||
|
NEXT_PRIVATE_MAILCHANNELS_ENDPOINT=
|
||||||
|
# OPTIONAL: The domain to use for DKIM signing.
|
||||||
|
NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN=
|
||||||
|
# OPTIONAL: The selector to use for DKIM signing.
|
||||||
|
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=
|
||||||
|
# OPTIONAL: The private key to use for DKIM signing.
|
||||||
|
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
|
||||||
|
|
||||||
|
# [[STRIPE]]
|
||||||
|
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||||
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
||||||
|
|
||||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
# [[FEATURES]]
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
|
||||||
|
|
||||||
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false
|
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false
|
||||||
|
|
||||||
# This is only required for the marketing site
|
# This is only required for the marketing site
|
||||||
|
# [[REDIS]]
|
||||||
NEXT_PRIVATE_REDIS_URL=
|
NEXT_PRIVATE_REDIS_URL=
|
||||||
NEXT_PRIVATE_REDIS_TOKEN=
|
NEXT_PRIVATE_REDIS_TOKEN=
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"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",
|
||||||
"next": "13.4.1",
|
"next": "13.4.9",
|
||||||
"next-auth": "^4.22.1",
|
"next-auth": "^4.22.1",
|
||||||
"next-plausible": "^3.7.2",
|
"next-plausible": "^3.7.2",
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
|
|||||||
@@ -7,9 +7,23 @@ const { parsed: env } = require('dotenv').config({
|
|||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
experimental: {
|
||||||
|
serverActions: true,
|
||||||
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
transpilePackages: [
|
||||||
|
'@documenso/lib',
|
||||||
|
'@documenso/prisma',
|
||||||
|
'@documenso/trpc',
|
||||||
|
'@documenso/ui',
|
||||||
|
'@documenso/email',
|
||||||
|
],
|
||||||
env,
|
env,
|
||||||
|
modularizeImports: {
|
||||||
|
'lucide-react': {
|
||||||
|
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"lucide-react": "^0.214.0",
|
"lucide-react": "^0.214.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"next": "13.4.1",
|
"next": "13.4.9",
|
||||||
"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",
|
||||||
|
|||||||
BIN
apps/web/public/static/clock.png
Normal file
BIN
apps/web/public/static/clock.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
apps/web/public/static/completed.png
Normal file
BIN
apps/web/public/static/completed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
apps/web/public/static/document.png
Normal file
BIN
apps/web/public/static/document.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/web/public/static/download.png
Normal file
BIN
apps/web/public/static/download.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 784 B |
BIN
apps/web/public/static/logo.png
Normal file
BIN
apps/web/public/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/web/public/static/review.png
Normal file
BIN
apps/web/public/static/review.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 708 B |
111
apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
Normal file
111
apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
|
import { AddFieldsFormPartial } from '~/components/forms/edit-document/add-fields';
|
||||||
|
import { AddSignersFormPartial } from '~/components/forms/edit-document/add-signers';
|
||||||
|
import { AddSubjectFormPartial } from '~/components/forms/edit-document/add-subject';
|
||||||
|
|
||||||
|
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="dark:bg-background flex-col flex min-h-[80vh] items-center justify-center bg-white/50">
|
||||||
|
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type EditDocumentFormProps = {
|
||||||
|
className?: string;
|
||||||
|
user: User;
|
||||||
|
document: Document;
|
||||||
|
recipients: Recipient[];
|
||||||
|
fields: Field[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditDocumentForm = ({
|
||||||
|
className,
|
||||||
|
document,
|
||||||
|
recipients,
|
||||||
|
fields,
|
||||||
|
user: _user,
|
||||||
|
}: EditDocumentFormProps) => {
|
||||||
|
const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers');
|
||||||
|
|
||||||
|
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||||
|
|
||||||
|
const onNextStep = () => {
|
||||||
|
if (step === 'signers') {
|
||||||
|
setStep('fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'fields') {
|
||||||
|
setStep('subject');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPreviousStep = () => {
|
||||||
|
if (step === 'fields') {
|
||||||
|
setStep('signers');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'subject') {
|
||||||
|
setStep('fields');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||||
|
<Card
|
||||||
|
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
|
gradient
|
||||||
|
>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<PDFViewer document={documentUrl} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
|
{step === 'signers' && (
|
||||||
|
<AddSignersFormPartial
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
document={document}
|
||||||
|
onContinue={onNextStep}
|
||||||
|
onGoBack={onPreviousStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'fields' && (
|
||||||
|
<AddFieldsFormPartial
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
document={document}
|
||||||
|
onContinue={onNextStep}
|
||||||
|
onGoBack={onPreviousStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'subject' && (
|
||||||
|
<AddSubjectFormPartial
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
document={document}
|
||||||
|
onContinue={onNextStep}
|
||||||
|
onGoBack={onPreviousStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,7 +16,7 @@ export type LoadablePDFCard = PDFViewerProps & {
|
|||||||
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
|
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
<div className="flex-col flex min-h-[80vh] items-center justify-center bg-white/50">
|
||||||
<Loader className="h-12 w-12 animate-spin text-slate-500" />
|
<Loader className="h-12 w-12 animate-spin text-slate-500" />
|
||||||
|
|
||||||
<p className="mt-4 text-slate-500">Loading document...</p>
|
<p className="mt-4 text-slate-500">Loading document...</p>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ChevronLeft, Loader } from 'lucide-react';
|
|||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
<div className="flex-col mx-auto -mt-4 flex w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Documents
|
Documents
|
||||||
@@ -13,15 +13,15 @@ export default function Loading() {
|
|||||||
Loading Document...
|
Loading Document...
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mt-8 grid min-h-[80vh] w-full grid-cols-12 gap-x-8">
|
<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="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||||
<div className="flex min-h-[80vh] flex-col items-center justify-center">
|
<div className="flex-col flex min-h-[80vh] items-center justify-center">
|
||||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-background border-documenso col-span-5 rounded-xl border-2 before:rounded-xl" />
|
<div className="bg-background border-border col-span-12 rounded-xl border-2 before:rounded-xl lg:col-span-6 xl:col-span-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft, Users2 } 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 { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
|
||||||
import { EditDocumentForm } from '~/components/forms/edit-document';
|
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||||
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
export type DocumentPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@@ -61,6 +62,18 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
{document.title}
|
{document.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
||||||
|
|
||||||
|
{recipients.length > 0 && (
|
||||||
|
<div className="text-muted-foreground flex items-center">
|
||||||
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
|
<span>{recipients.length} Recipient(s)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<EditDocumentForm
|
<EditDocumentForm
|
||||||
className="mt-8"
|
className="mt-8"
|
||||||
document={document}
|
document={document}
|
||||||
|
|||||||
18
apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx
Normal file
18
apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function DocumentSentPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex-col mx-auto -mt-4 flex w-full max-w-screen-xl 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Inter } from 'next/font/google';
|
|||||||
|
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||||
|
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
import { ThemeProvider } from '~/providers/next-theme';
|
import { ThemeProvider } from '~/providers/next-theme';
|
||||||
import { PlausibleProvider } from '~/providers/plausible';
|
import { PlausibleProvider } from '~/providers/plausible';
|
||||||
@@ -47,7 +48,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<body>
|
<body>
|
||||||
<PlausibleProvider>
|
<PlausibleProvider>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<TrpcProvider>{children}</TrpcProvider>
|
<TooltipProvider>
|
||||||
|
<TrpcProvider>{children}</TrpcProvider>
|
||||||
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</PlausibleProvider>
|
</PlausibleProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@@ -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)}
|
||||||
@@ -105,20 +102,19 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
|
|||||||
<Card
|
<Card
|
||||||
role="button"
|
role="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'focus-visible:ring-ring ring-offset-background flex flex-1 cursor-pointer flex-col items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
'focus-visible:ring-ring ring-offset-background flex-col flex flex-1 cursor-pointer items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
gradient={true}
|
gradient={true}
|
||||||
degrees={120}
|
degrees={120}
|
||||||
|
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CardContent className="text-muted-foreground/40 flex flex-col items-center justify-center p-6">
|
<CardContent className="text-muted-foreground/40 flex-col flex items-center justify-center p-6">
|
||||||
{/* <FilePlus strokeWidth="1px" className="h-16 w-16"/> */}
|
{/* <FilePlus strokeWidth="1px" className="h-16 w-16"/> */}
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<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 flex-col z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
|
||||||
variants={DocumentDropzoneCardLeftVariants}
|
variants={DocumentDropzoneCardLeftVariants}
|
||||||
>
|
>
|
||||||
<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]" />
|
||||||
@@ -127,7 +123,7 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<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 flex-col z-20 flex aspect-[3/4] w-24 items-center justify-center gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
|
||||||
variants={DocumentDropzoneCardCenterVariants}
|
variants={DocumentDropzoneCardCenterVariants}
|
||||||
>
|
>
|
||||||
<Plus
|
<Plus
|
||||||
@@ -137,7 +133,7 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<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 flex-col z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
|
||||||
variants={DocumentDropzoneCardRightVariants}
|
variants={DocumentDropzoneCardRightVariants}
|
||||||
>
|
>
|
||||||
<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]" />
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import React from 'react';
|
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||||
|
|
||||||
import { LucideIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
@@ -15,7 +13,7 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-border bg-background overflow-hidden rounded-lg border shadow shadow-transparent duration-200 hover:shadow-slate-100',
|
'border-border bg-background hover:shadow-border/80 overflow-hidden rounded-lg border shadow shadow-transparent duration-200',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { CheckCircle2, Clock, File, LucideIcon } from 'lucide-react';
|
import { CheckCircle2, Clock, File } from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||||
|
|
||||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -31,14 +32,24 @@ const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = {
|
|||||||
|
|
||||||
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
|
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
|
||||||
status: InternalDocumentStatus;
|
status: InternalDocumentStatus;
|
||||||
|
inheritColor?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentStatus = ({ className, status, ...props }: DocumentStatusProps) => {
|
export const DocumentStatus = ({
|
||||||
|
className,
|
||||||
|
status,
|
||||||
|
inheritColor,
|
||||||
|
...props
|
||||||
|
}: DocumentStatusProps) => {
|
||||||
const { label, icon: Icon, color } = FRIENDLY_STATUS_MAP[status];
|
const { label, icon: Icon, color } = FRIENDLY_STATUS_MAP[status];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={cn('flex items-center', className)} {...props}>
|
<span className={cn('flex items-center', className)} {...props}>
|
||||||
<Icon className={cn('mr-2 inline-block h-4 w-4', color)} />
|
<Icon
|
||||||
|
className={cn('mr-2 inline-block h-4 w-4', {
|
||||||
|
[color]: !inheritColor,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,251 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useId, useState } from 'react';
|
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
|
|
||||||
import { AddFieldsFormPartial } from './edit-document/add-fields';
|
|
||||||
import { AddSignersFormPartial } from './edit-document/add-signers';
|
|
||||||
import { AddSubjectFormPartial } from './edit-document/add-subject';
|
|
||||||
import { TEditDocumentFormSchema, ZEditDocumentFormSchema } from './edit-document/types';
|
|
||||||
|
|
||||||
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
|
||||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const MAX_STEP = 2;
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
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,
|
|
||||||
watch,
|
|
||||||
trigger,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<TEditDocumentFormSchema>({
|
|
||||||
mode: 'onBlur',
|
|
||||||
defaultValues: {
|
|
||||||
signers: defaultSigners,
|
|
||||||
fields: defaultFields,
|
|
||||||
email: {
|
|
||||||
subject: '',
|
|
||||||
message: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZEditDocumentFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const signersFormValue = watch('signers');
|
|
||||||
const fieldsFormValue = watch('fields');
|
|
||||||
|
|
||||||
console.log({ state: watch(), errors });
|
|
||||||
|
|
||||||
const canGoBack = step > 0;
|
|
||||||
const canGoNext = 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);
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
<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)}
|
|
||||||
>
|
|
||||||
{step === 0 && (
|
|
||||||
<AddSignersFormPartial
|
|
||||||
className="-mx-2 flex-1 overflow-y-hidden px-2"
|
|
||||||
control={control}
|
|
||||||
watch={watch}
|
|
||||||
errors={errors}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 1 && (
|
|
||||||
<AddFieldsFormPartial
|
|
||||||
className="-mx-2 flex-1 overflow-y-hidden px-2"
|
|
||||||
control={control}
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 flex-shrink-0">
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Add Signers ({step + 1}/{MAX_STEP + 1})
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-muted relative mt-4 h-[2px] rounded-md">
|
|
||||||
<div
|
|
||||||
className="bg-documenso absolute inset-y-0 left-0"
|
|
||||||
style={{
|
|
||||||
width: `${(100 / (MAX_STEP + 1)) * (step + 1)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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"
|
|
||||||
disabled={!canGoBack}
|
|
||||||
onClick={onGoBackClick}
|
|
||||||
>
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||||
|
|
||||||
|
import { TAddFieldsFormSchema } from './add-fields.types';
|
||||||
|
|
||||||
|
export type AddFieldsActionInput = TAddFieldsFormSchema & {
|
||||||
|
documentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addFields = async ({ documentId, fields }: AddFieldsActionInput) => {
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
const { id: userId } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
await setFieldsForDocument({
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
fields: fields.map((field) => ({
|
||||||
|
id: field.nativeId,
|
||||||
|
signerEmail: field.signerEmail,
|
||||||
|
type: field.type,
|
||||||
|
pageNumber: field.pageNumber,
|
||||||
|
pageX: field.pageX,
|
||||||
|
pageY: field.pageY,
|
||||||
|
pageWidth: field.pageWidth,
|
||||||
|
pageHeight: field.pageHeight,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -3,12 +3,13 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Caveat } from 'next/font/google';
|
import { Caveat } from 'next/font/google';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
import { Check, ChevronsUpDown, Info } from 'lucide-react';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { Control, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { Document, Field, FieldType, Recipient, SendStatus } 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';
|
||||||
@@ -20,11 +21,23 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
} 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 { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
|
||||||
|
import { getBoundingClientRect } from '~/helpers/getBoundingClientRect';
|
||||||
|
|
||||||
|
import { addFields } from './add-fields.action';
|
||||||
|
import { TAddFieldsFormSchema } from './add-fields.types';
|
||||||
|
import {
|
||||||
|
EditDocumentFormContainer,
|
||||||
|
EditDocumentFormContainerActions,
|
||||||
|
EditDocumentFormContainerContent,
|
||||||
|
EditDocumentFormContainerFooter,
|
||||||
|
EditDocumentFormContainerStep,
|
||||||
|
} from './container';
|
||||||
import { FieldItem } from './field-item';
|
import { FieldItem } from './field-item';
|
||||||
import { FRIENDLY_FIELD_TYPE, TEditDocumentFormSchema } from './types';
|
import { FRIENDLY_FIELD_TYPE } from './types';
|
||||||
|
|
||||||
const fontCaveat = Caveat({
|
const fontCaveat = Caveat({
|
||||||
weight: ['500'],
|
weight: ['500'],
|
||||||
@@ -40,31 +53,58 @@ const MIN_HEIGHT_PX = 60;
|
|||||||
const MIN_WIDTH_PX = 200;
|
const MIN_WIDTH_PX = 200;
|
||||||
|
|
||||||
export type AddFieldsFormProps = {
|
export type AddFieldsFormProps = {
|
||||||
className?: string;
|
recipients: Recipient[];
|
||||||
control: Control<TEditDocumentFormSchema>;
|
fields: Field[];
|
||||||
watch: UseFormWatch<TEditDocumentFormSchema>;
|
document: Document;
|
||||||
errors: FieldErrors<TEditDocumentFormSchema>;
|
onContinue?: () => void;
|
||||||
isSubmitting: boolean;
|
onGoBack?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddFieldsFormPartial = ({
|
export const AddFieldsFormPartial = ({
|
||||||
className,
|
recipients,
|
||||||
control: control,
|
fields,
|
||||||
watch,
|
document,
|
||||||
errors: _errors,
|
onContinue,
|
||||||
isSubmitting: _isSubmitting,
|
onGoBack,
|
||||||
}: AddFieldsFormProps) => {
|
}: AddFieldsFormProps) => {
|
||||||
const signers = watch('signers');
|
const { toast } = useToast();
|
||||||
const fields = watch('fields');
|
const router = useRouter();
|
||||||
|
|
||||||
const { append, remove, update } = useFieldArray({
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = useForm<TAddFieldsFormSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
fields: 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 {
|
||||||
|
append,
|
||||||
|
remove,
|
||||||
|
update,
|
||||||
|
fields: localFields,
|
||||||
|
} = useFieldArray({
|
||||||
control,
|
control,
|
||||||
name: 'fields',
|
name: 'fields',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedSigner, setSelectedSigner] = useState(() => signers[0]);
|
|
||||||
|
|
||||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||||
|
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
|
||||||
|
|
||||||
|
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
|
||||||
|
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [coords, setCoords] = useState({
|
const [coords, setCoords] = useState({
|
||||||
@@ -77,14 +117,60 @@ export const AddFieldsFormPartial = ({
|
|||||||
width: 0,
|
width: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isWithinPageBounds = useCallback((event: MouseEvent) => {
|
/**
|
||||||
|
* Given a mouse event, find the nearest pdf page element.
|
||||||
|
*/
|
||||||
|
const getPage = (event: MouseEvent) => {
|
||||||
if (!(event.target instanceof HTMLElement)) {
|
if (!(event.target instanceof HTMLElement)) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
|
|
||||||
const $page =
|
const $page =
|
||||||
target.closest(PDF_VIEWER_PAGE_SELECTOR) ?? target.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
target.closest<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR) ??
|
||||||
|
target.querySelector<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $page;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provided a page and a field, calculate the position of the field
|
||||||
|
* as a percentage of the page width and height.
|
||||||
|
*/
|
||||||
|
const getFieldPosition = (page: HTMLElement, field: HTMLElement) => {
|
||||||
|
const {
|
||||||
|
top: pageTop,
|
||||||
|
left: pageLeft,
|
||||||
|
height: pageHeight,
|
||||||
|
width: pageWidth,
|
||||||
|
} = getBoundingClientRect(page);
|
||||||
|
|
||||||
|
const {
|
||||||
|
top: fieldTop,
|
||||||
|
left: fieldLeft,
|
||||||
|
height: fieldHeight,
|
||||||
|
width: fieldWidth,
|
||||||
|
} = getBoundingClientRect(field);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: ((fieldLeft - pageLeft) / pageWidth) * 100,
|
||||||
|
y: ((fieldTop - pageTop) / pageHeight) * 100,
|
||||||
|
width: (fieldWidth / pageWidth) * 100,
|
||||||
|
height: (fieldHeight / pageHeight) * 100,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a mouse event, determine if the mouse is within the bounds of the
|
||||||
|
* nearest pdf page element.
|
||||||
|
*/
|
||||||
|
const isWithinPageBounds = useCallback((event: MouseEvent) => {
|
||||||
|
const $page = getPage(event);
|
||||||
|
|
||||||
if (!$page) {
|
if (!$page) {
|
||||||
return false;
|
return false;
|
||||||
@@ -121,28 +207,17 @@ export const AddFieldsFormPartial = ({
|
|||||||
|
|
||||||
const onMouseClick = useCallback(
|
const onMouseClick = useCallback(
|
||||||
(event: MouseEvent) => {
|
(event: MouseEvent) => {
|
||||||
if (!selectedField) {
|
if (!selectedField || !selectedSigner) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(event.target instanceof HTMLElement)) {
|
const $page = getPage(event);
|
||||||
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)) {
|
if (!$page || !isWithinPageBounds(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { height, width } = $page.getBoundingClientRect();
|
const { top, left, height, width } = getBoundingClientRect($page);
|
||||||
|
|
||||||
const top = $page.offsetTop;
|
|
||||||
const left = $page.offsetLeft;
|
|
||||||
|
|
||||||
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||||
|
|
||||||
@@ -172,14 +247,14 @@ export const AddFieldsFormPartial = ({
|
|||||||
setVisible(false);
|
setVisible(false);
|
||||||
setSelectedField(null);
|
setSelectedField(null);
|
||||||
},
|
},
|
||||||
[append, isWithinPageBounds, selectedField, selectedSigner.email],
|
[append, isWithinPageBounds, selectedField, selectedSigner],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFieldResize = useCallback(
|
const onFieldResize = useCallback(
|
||||||
(node: HTMLElement, index: number) => {
|
(node: HTMLElement, index: number) => {
|
||||||
const field = fields[index];
|
const field = localFields[index];
|
||||||
|
|
||||||
const $page = document.querySelector<HTMLElement>(
|
const $page = window.document.querySelector<HTMLElement>(
|
||||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -187,71 +262,69 @@ export const AddFieldsFormPartial = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { height: pageHeight, width: pageWidth } = $page.getBoundingClientRect();
|
const {
|
||||||
|
x: pageX,
|
||||||
const pageTop = $page.offsetTop;
|
y: pageY,
|
||||||
const pageLeft = $page.offsetLeft;
|
width: pageWidth,
|
||||||
|
height: pageHeight,
|
||||||
let { top: nodeTop, left: nodeLeft } = node.getBoundingClientRect();
|
} = getFieldPosition($page, node);
|
||||||
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, {
|
update(index, {
|
||||||
...field,
|
...field,
|
||||||
pageX: newX,
|
pageX,
|
||||||
pageY: newY,
|
pageY,
|
||||||
pageWidth: newWidth,
|
pageWidth,
|
||||||
pageHeight: newHeight,
|
pageHeight,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[fields, update],
|
[localFields, update],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFieldMove = useCallback(
|
const onFieldMove = useCallback(
|
||||||
(node: HTMLElement, index: number) => {
|
(node: HTMLElement, index: number) => {
|
||||||
const field = fields[index];
|
const field = localFields[index];
|
||||||
|
|
||||||
const $page = document.querySelector(
|
const $page = window.document.querySelector<HTMLElement>(
|
||||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$page || !($page instanceof HTMLElement)) {
|
if (!$page) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { height: pageHeight, width: pageWidth } = $page.getBoundingClientRect();
|
const { x: pageX, y: pageY } = getFieldPosition($page, node);
|
||||||
|
|
||||||
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, {
|
update(index, {
|
||||||
...field,
|
...field,
|
||||||
pageX: newX,
|
pageX,
|
||||||
pageY: newY,
|
pageY,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[fields, update],
|
[localFields, update],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onFormSubmit = handleSubmit(async (data: TAddFieldsFormSchema) => {
|
||||||
|
try {
|
||||||
|
// Custom invocation server action
|
||||||
|
await addFields({
|
||||||
|
documentId: document.id,
|
||||||
|
fields: data.fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
onContinue?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while adding signers.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedField) {
|
if (selectedField) {
|
||||||
window.addEventListener('mousemove', onMouseMove);
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
@@ -265,7 +338,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
}, [onMouseClick, onMouseMove, selectedField]);
|
}, [onMouseClick, onMouseMove, selectedField]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
const $page = window.document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||||
|
|
||||||
if (!$page) {
|
if (!$page) {
|
||||||
return;
|
return;
|
||||||
@@ -279,183 +352,226 @@ export const AddFieldsFormPartial = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
|
||||||
|
}, [recipients]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<EditDocumentFormContainer>
|
||||||
{selectedField && visible && (
|
<EditDocumentFormContainerContent
|
||||||
<Card
|
title="Add Fields"
|
||||||
className="border-primary pointer-events-none fixed z-50 cursor-pointer bg-white"
|
description="Add all relevant fields for each recipient."
|
||||||
style={{
|
>
|
||||||
top: coords.y,
|
<div className="flex-col flex">
|
||||||
left: coords.x,
|
{selectedField && visible && (
|
||||||
height: fieldBounds.current.height,
|
<Card
|
||||||
width: fieldBounds.current.width,
|
className="border-primary pointer-events-none fixed z-50 cursor-pointer bg-white"
|
||||||
}}
|
style={{
|
||||||
>
|
top: coords.y,
|
||||||
<CardContent className="text-foreground flex h-full w-full items-center justify-center p-2">
|
left: coords.x,
|
||||||
{FRIENDLY_FIELD_TYPE[selectedField]}
|
height: fieldBounds.current.height,
|
||||||
</CardContent>
|
width: fieldBounds.current.width,
|
||||||
</Card>
|
}}
|
||||||
)}
|
>
|
||||||
|
<CardContent className="text-foreground flex h-full w-full items-center justify-center p-2">
|
||||||
{fields.map((field, index) => (
|
{FRIENDLY_FIELD_TYPE[selectedField]}
|
||||||
<FieldItem
|
|
||||||
key={index}
|
|
||||||
field={field}
|
|
||||||
disabled={selectedSigner.email !== field.signerEmail}
|
|
||||||
minHeight={fieldBounds.current.height}
|
|
||||||
minWidth={fieldBounds.current.width}
|
|
||||||
passive={visible && !!selectedField}
|
|
||||||
onResize={(options) => onFieldResize(options, index)}
|
|
||||||
onMove={(options) => onFieldMove(options, index)}
|
|
||||||
onRemove={() => remove(index)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<h3 className="text-2xl font-semibold">Add Fields</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
Add all relevant fields for each recipient.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="mb-8 mt-4" />
|
|
||||||
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className="bg-background text-muted-foreground justify-between font-normal"
|
|
||||||
>
|
|
||||||
{selectedSigner.name && (
|
|
||||||
<span>
|
|
||||||
{selectedSigner.name} ({selectedSigner.email})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!selectedSigner.name && <span>{selectedSigner.email}</span>}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
|
|
||||||
<PopoverContent className="p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput />
|
|
||||||
<CommandEmpty />
|
|
||||||
|
|
||||||
<CommandGroup>
|
|
||||||
{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,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
{signer.name && (
|
|
||||||
<span>
|
|
||||||
{signer.name} ({signer.email})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!signer.name && <span>{signer.email}</span>}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<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',
|
|
||||||
fontCaveat.className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{selectedSigner.name || 'Signature'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</button>
|
)}
|
||||||
|
|
||||||
<button
|
{localFields.map((field, index) => (
|
||||||
type="button"
|
<FieldItem
|
||||||
className="group h-full w-full"
|
key={index}
|
||||||
onClick={() => setSelectedField(FieldType.EMAIL)}
|
field={field}
|
||||||
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
|
||||||
>
|
minHeight={fieldBounds.current.height}
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
|
minWidth={fieldBounds.current.width}
|
||||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
passive={visible && !!selectedField}
|
||||||
<p
|
onResize={(options) => onFieldResize(options, index)}
|
||||||
className={cn(
|
onMove={(options) => onFieldMove(options, index)}
|
||||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
onRemove={() => remove(index)}
|
||||||
)}
|
/>
|
||||||
>
|
))}
|
||||||
{'Email'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">Email</p>
|
<Popover>
|
||||||
</CardContent>
|
<PopoverTrigger asChild>
|
||||||
</Card>
|
<Button
|
||||||
</button>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="bg-background text-muted-foreground justify-between font-normal"
|
||||||
|
>
|
||||||
|
{selectedSigner?.email && (
|
||||||
|
<span className="flex-1 truncate text-left">
|
||||||
|
{selectedSigner?.email} ({selectedSigner?.email})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
{!selectedSigner?.email && (
|
||||||
type="button"
|
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
|
||||||
className="group h-full w-full"
|
)}
|
||||||
onClick={() => setSelectedField(FieldType.NAME)}
|
|
||||||
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
|
||||||
>
|
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{'Name'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">Name</p>
|
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||||
</CardContent>
|
</Button>
|
||||||
</Card>
|
</PopoverTrigger>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<PopoverContent className="p-0" align="start">
|
||||||
type="button"
|
<Command>
|
||||||
className="group h-full w-full"
|
<CommandInput />
|
||||||
onClick={() => setSelectedField(FieldType.DATE)}
|
<CommandEmpty />
|
||||||
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
|
||||||
>
|
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{'Date'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">Date</p>
|
<CommandGroup>
|
||||||
</CardContent>
|
{recipients.map((recipient, index) => (
|
||||||
</Card>
|
<CommandItem
|
||||||
</button>
|
key={index}
|
||||||
|
className={cn({
|
||||||
|
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||||
|
})}
|
||||||
|
onSelect={() => setSelectedSigner(recipient)}
|
||||||
|
>
|
||||||
|
{recipient.sendStatus !== SendStatus.SENT ? (
|
||||||
|
<Check
|
||||||
|
aria-hidden={recipient !== selectedSigner}
|
||||||
|
className={cn('mr-2 h-4 w-4 flex-shrink-0', {
|
||||||
|
'opacity-0': recipient !== selectedSigner,
|
||||||
|
'opacity-100': recipient === selectedSigner,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Info className="mr-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
This document has already been sent to this recipient. You can no longer
|
||||||
|
edit this recipient.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipient.name && (
|
||||||
|
<span className="truncate" title={`${recipient.name} (${recipient.email})`}>
|
||||||
|
{recipient.name} ({recipient.email})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!recipient.name && (
|
||||||
|
<span className="truncate" title={recipient.email}>
|
||||||
|
{recipient.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||||
|
onClick={() => setSelectedField(FieldType.SIGNATURE)}
|
||||||
|
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||||
|
>
|
||||||
|
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||||
|
<CardContent className="flex-col flex items-center justify-center px-6 py-4">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground group-data-[selected]:text-foreground text-3xl font-medium',
|
||||||
|
fontCaveat.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedSigner?.name || 'Signature'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group h-full w-full"
|
||||||
|
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||||
|
onClick={() => setSelectedField(FieldType.EMAIL)}
|
||||||
|
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||||
|
>
|
||||||
|
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||||
|
<CardContent className="flex-col flex items-center justify-center px-6 py-4">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{'Email'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">Email</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group h-full w-full"
|
||||||
|
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||||
|
onClick={() => setSelectedField(FieldType.NAME)}
|
||||||
|
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||||
|
>
|
||||||
|
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||||
|
<CardContent className="flex-col flex items-center justify-center px-6 py-4">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{'Name'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">Name</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group h-full w-full"
|
||||||
|
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||||
|
onClick={() => setSelectedField(FieldType.DATE)}
|
||||||
|
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||||
|
>
|
||||||
|
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||||
|
<CardContent className="flex-col flex items-center justify-center px-6 py-4">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{'Date'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">Date</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</EditDocumentFormContainerContent>
|
||||||
</div>
|
|
||||||
|
<EditDocumentFormContainerFooter>
|
||||||
|
<EditDocumentFormContainerStep title="Add Fields" step={2} maxStep={3} />
|
||||||
|
|
||||||
|
<EditDocumentFormContainerActions
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onGoNextClick={() => onFormSubmit()}
|
||||||
|
onGoBackClick={onGoBack}
|
||||||
|
/>
|
||||||
|
</EditDocumentFormContainerFooter>
|
||||||
|
</EditDocumentFormContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const ZAddFieldsFormSchema = z.object({
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAddFieldsFormSchema = z.infer<typeof ZAddFieldsFormSchema>;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||||
|
|
||||||
|
import { TAddSignersFormSchema } from './add-signers.types';
|
||||||
|
|
||||||
|
export type AddSignersActionInput = TAddSignersFormSchema & {
|
||||||
|
documentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addSigners = async ({ documentId, signers }: AddSignersActionInput) => {
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
const { id: userId } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
await setRecipientsForDocument({
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
recipients: signers.map((signer) => ({
|
||||||
|
id: signer.nativeId,
|
||||||
|
email: signer.email,
|
||||||
|
name: signer.name,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,35 +1,76 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useId } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
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 { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { Control, Controller, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form';
|
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { Document, Field, Recipient, SendStatus } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { FormErrorMessage } from '~/components/form/form-error-message';
|
import { FormErrorMessage } from '~/components/form/form-error-message';
|
||||||
|
|
||||||
import { TEditDocumentFormSchema } from './types';
|
import { addSigners } from './add-signers.action';
|
||||||
|
import { TAddSignersFormSchema } from './add-signers.types';
|
||||||
|
import {
|
||||||
|
EditDocumentFormContainer,
|
||||||
|
EditDocumentFormContainerActions,
|
||||||
|
EditDocumentFormContainerContent,
|
||||||
|
EditDocumentFormContainerFooter,
|
||||||
|
EditDocumentFormContainerStep,
|
||||||
|
} from './container';
|
||||||
|
|
||||||
export type AddSignersFormProps = {
|
export type AddSignersFormProps = {
|
||||||
className?: string;
|
recipients: Recipient[];
|
||||||
control: Control<TEditDocumentFormSchema>;
|
fields: Field[];
|
||||||
watch: UseFormWatch<TEditDocumentFormSchema>;
|
document: Document;
|
||||||
errors: FieldErrors<TEditDocumentFormSchema>;
|
onContinue?: () => void;
|
||||||
isSubmitting: boolean;
|
onGoBack?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddSignersFormPartial = ({
|
export const AddSignersFormPartial = ({
|
||||||
className,
|
recipients,
|
||||||
control,
|
fields: _fields,
|
||||||
errors,
|
document: document,
|
||||||
isSubmitting,
|
onContinue,
|
||||||
|
onGoBack,
|
||||||
}: AddSignersFormProps) => {
|
}: AddSignersFormProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const initialId = useId();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<TAddSignersFormSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
signers:
|
||||||
|
recipients.length > 0
|
||||||
|
? recipients.map((recipient) => ({
|
||||||
|
nativeId: recipient.id,
|
||||||
|
formId: String(recipient.id),
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
formId: initialId,
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
append: appendSigner,
|
append: appendSigner,
|
||||||
fields: signers,
|
fields: signers,
|
||||||
@@ -39,10 +80,15 @@ export const AddSignersFormPartial = ({
|
|||||||
name: 'signers',
|
name: 'signers',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { remove: removeField, fields: fields } = useFieldArray({
|
const hasBeenSentToRecipientId = (id?: number) => {
|
||||||
name: 'fields',
|
if (!id) {
|
||||||
control,
|
return false;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
return recipients.some(
|
||||||
|
(recipient) => recipient.id === id && recipient.sendStatus === SendStatus.SENT,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const onAddSigner = () => {
|
const onAddSigner = () => {
|
||||||
appendSigner({
|
appendSigner({
|
||||||
@@ -55,17 +101,17 @@ export const AddSignersFormPartial = ({
|
|||||||
const onRemoveSigner = (index: number) => {
|
const onRemoveSigner = (index: number) => {
|
||||||
const signer = signers[index];
|
const signer = signers[index];
|
||||||
|
|
||||||
|
if (hasBeenSentToRecipientId(signer.nativeId)) {
|
||||||
|
toast({
|
||||||
|
title: 'Cannot remove signer',
|
||||||
|
description: 'This signer has already received the document.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
removeSigner(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>) => {
|
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
@@ -74,21 +120,42 @@ export const AddSignersFormPartial = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = handleSubmit(async (data: TAddSignersFormSchema) => {
|
||||||
|
try {
|
||||||
|
// Custom invocation server action
|
||||||
|
await addSigners({
|
||||||
|
documentId: document.id,
|
||||||
|
signers: data.signers,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
onContinue?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while adding signers.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<EditDocumentFormContainer onSubmit={onFormSubmit}>
|
||||||
<h3 className="text-foreground text-2xl font-semibold">Add Signers</h3>
|
<EditDocumentFormContainerContent
|
||||||
|
title="Add Signers"
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
description="Add the people who will sign the document."
|
||||||
Add the people who will sign the document.
|
>
|
||||||
</p>
|
<div className="flex-col flex w-full gap-y-4">
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
|
||||||
|
|
||||||
<div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">
|
|
||||||
<div className="flex w-full flex-col gap-y-4">
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{signers.map((signer, index) => (
|
{signers.map((signer, index) => (
|
||||||
<motion.div key={signer.formId} className="flex flex-wrap items-end gap-x-4">
|
<motion.div
|
||||||
|
key={signer.formId}
|
||||||
|
data-native-id={signer.nativeId}
|
||||||
|
className="flex flex-wrap items-end gap-x-4"
|
||||||
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label htmlFor={`signer-${signer.formId}-email`}>
|
<Label htmlFor={`signer-${signer.formId}-email`}>
|
||||||
Email
|
Email
|
||||||
@@ -103,7 +170,7 @@ export const AddSignersFormPartial = ({
|
|||||||
id={`signer-${signer.formId}-email`}
|
id={`signer-${signer.formId}-email`}
|
||||||
type="email"
|
type="email"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@@ -122,7 +189,7 @@ export const AddSignersFormPartial = ({
|
|||||||
id={`signer-${signer.formId}-name`}
|
id={`signer-${signer.formId}-name`}
|
||||||
type="text"
|
type="text"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@@ -134,7 +201,11 @@ export const AddSignersFormPartial = ({
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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}
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
hasBeenSentToRecipientId(signer.nativeId) ||
|
||||||
|
signers.length === 1
|
||||||
|
}
|
||||||
onClick={() => onRemoveSigner(index)}
|
onClick={() => onRemoveSigner(index)}
|
||||||
>
|
>
|
||||||
<Trash className="h-5 w-5" />
|
<Trash className="h-5 w-5" />
|
||||||
@@ -158,7 +229,18 @@ export const AddSignersFormPartial = ({
|
|||||||
Add Signer
|
Add Signer
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</EditDocumentFormContainerContent>
|
||||||
</div>
|
|
||||||
|
<EditDocumentFormContainerFooter>
|
||||||
|
<EditDocumentFormContainerStep title="Add Signers" step={1} maxStep={3} />
|
||||||
|
|
||||||
|
<EditDocumentFormContainerActions
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onGoNextClick={() => onFormSubmit()}
|
||||||
|
onGoBackClick={onGoBack}
|
||||||
|
/>
|
||||||
|
</EditDocumentFormContainerFooter>
|
||||||
|
</EditDocumentFormContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZAddSignersFormSchema = 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'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||||
|
|
||||||
|
import { TAddSubjectFormSchema } from './add-subject.types';
|
||||||
|
|
||||||
|
export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
|
||||||
|
documentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const completeDocument = async ({ documentId }: CompleteDocumentActionInput) => {
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
const { id: userId } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
await sendDocument({
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,111 +1,169 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Control, Controller, FieldErrors, UseFormWatch } from 'react-hook-form';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { Document, DocumentStatus, Field, Recipient } from '@documenso/prisma/client';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { FormErrorMessage } from '~/components/form/form-error-message';
|
import { FormErrorMessage } from '~/components/form/form-error-message';
|
||||||
|
|
||||||
import { TEditDocumentFormSchema } from './types';
|
import { completeDocument } from './add-subject.action';
|
||||||
|
import { TAddSubjectFormSchema } from './add-subject.types';
|
||||||
|
import {
|
||||||
|
EditDocumentFormContainer,
|
||||||
|
EditDocumentFormContainerActions,
|
||||||
|
EditDocumentFormContainerContent,
|
||||||
|
EditDocumentFormContainerFooter,
|
||||||
|
EditDocumentFormContainerStep,
|
||||||
|
} from './container';
|
||||||
|
|
||||||
export type AddSubjectFormProps = {
|
export type AddSubjectFormProps = {
|
||||||
className?: string;
|
recipients: Recipient[];
|
||||||
control: Control<TEditDocumentFormSchema>;
|
fields: Field[];
|
||||||
watch: UseFormWatch<TEditDocumentFormSchema>;
|
document: Document;
|
||||||
errors: FieldErrors<TEditDocumentFormSchema>;
|
onContinue?: () => void;
|
||||||
isSubmitting: boolean;
|
onGoBack?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddSubjectFormPartial = ({
|
export const AddSubjectFormPartial = ({
|
||||||
className,
|
recipients: _recipients,
|
||||||
control,
|
fields: _fields,
|
||||||
errors,
|
document,
|
||||||
isSubmitting,
|
onContinue,
|
||||||
|
onGoBack,
|
||||||
}: AddSubjectFormProps) => {
|
}: AddSubjectFormProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<TAddSubjectFormSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
email: {
|
||||||
|
subject: '',
|
||||||
|
message: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = handleSubmit(async (data: TAddSubjectFormSchema) => {
|
||||||
|
const { subject, message } = data.email;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await completeDocument({
|
||||||
|
documentId: document.id,
|
||||||
|
email: {
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
onContinue?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while sending the document.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<EditDocumentFormContainer>
|
||||||
<h3 className="text-foreground text-2xl font-semibold">Add Subject</h3>
|
<EditDocumentFormContainerContent
|
||||||
|
title="Add Subject"
|
||||||
|
description="Add the subject and message you wish to send to signers."
|
||||||
|
>
|
||||||
|
<div className="flex-col flex">
|
||||||
|
<div className="flex-col flex gap-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="subject">
|
||||||
|
Subject <span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
<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
|
<Input
|
||||||
id="subject"
|
id="subject"
|
||||||
// placeholder="Subject"
|
// placeholder="Subject"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...field}
|
{...register('email.subject')}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-2" errors={errors} />
|
<FormErrorMessage className="mt-2" error={errors.email?.subject} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="message">
|
<Label htmlFor="message">
|
||||||
Message <span className="text-muted-foreground">(Optional)</span>
|
Message <span className="text-muted-foreground">(Optional)</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="email.message"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Textarea
|
<Textarea
|
||||||
id="message"
|
id="message"
|
||||||
className="bg-background mt-2 h-32 resize-none"
|
className="bg-background mt-2 h-32 resize-none"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...field}
|
{...register('email.message')}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-2" errors={errors} />
|
<FormErrorMessage
|
||||||
|
className="mt-2"
|
||||||
|
error={
|
||||||
|
typeof errors.email?.message !== 'string' ? errors.email?.message : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
You can use the following variables in your message:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="flex-col mt-2 flex list-inside list-disc 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>
|
</div>
|
||||||
|
</EditDocumentFormContainerContent>
|
||||||
|
|
||||||
<div>
|
<EditDocumentFormContainerFooter>
|
||||||
<p className="text-muted-foreground text-sm">
|
<EditDocumentFormContainerStep title="Add Subject" step={3} maxStep={3} />
|
||||||
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">
|
<EditDocumentFormContainerActions
|
||||||
<li className="text-muted-foreground">
|
loading={isSubmitting}
|
||||||
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
|
disabled={isSubmitting}
|
||||||
{'{signer.name}'}
|
goNextLabel={document.status === DocumentStatus.DRAFT ? 'Send' : 'Update'}
|
||||||
</code>{' '}
|
onGoNextClick={() => onFormSubmit()}
|
||||||
- The signer's name
|
onGoBackClick={onGoBack}
|
||||||
</li>
|
/>
|
||||||
<li className="text-muted-foreground">
|
</EditDocumentFormContainerFooter>
|
||||||
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
|
</EditDocumentFormContainer>
|
||||||
{'{signer.email}'}
|
|
||||||
</code>{' '}
|
|
||||||
- The signer's email
|
|
||||||
</li>
|
|
||||||
<li className="text-muted-foreground">
|
|
||||||
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
|
|
||||||
{'{document.name}'}
|
|
||||||
</code>{' '}
|
|
||||||
- The document's name
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZAddSubjectFormSchema = z.object({
|
||||||
|
email: z.object({
|
||||||
|
subject: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAddSubjectFormSchema = z.infer<typeof ZAddSubjectFormSchema>;
|
||||||
152
apps/web/src/components/forms/edit-document/container.tsx
Normal file
152
apps/web/src/components/forms/edit-document/container.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import React, { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export type EditDocumentFormContainerProps = HTMLAttributes<HTMLFormElement> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditDocumentFormContainer = ({
|
||||||
|
children,
|
||||||
|
id = 'edit-document-form',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: EditDocumentFormContainerProps) => {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
id={id}
|
||||||
|
className={cn(
|
||||||
|
'dark:bg-background border-border bg-widget flex-col sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen rounded-xl border px-4 py-6',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn('flex-col -mx-2 flex flex-1 overflow-hidden px-2')}>{children}</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditDocumentFormContainerContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditDocumentFormContainerContent = ({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: EditDocumentFormContainerContentProps) => {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex-col flex flex-1', className)} {...props}>
|
||||||
|
<h3 className="text-foreground text-2xl font-semibold">{title}</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">{description}</p>
|
||||||
|
|
||||||
|
<hr className="border-border mb-8 mt-4" />
|
||||||
|
|
||||||
|
<div className="flex-col -mx-2 flex flex-1 overflow-y-auto px-2">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditDocumentFormContainerFooterProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditDocumentFormContainerFooter = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: EditDocumentFormContainerFooterProps) => {
|
||||||
|
return (
|
||||||
|
<div className={cn('mt-4 flex-shrink-0', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditDocumentFormContainerStepProps = {
|
||||||
|
title: string;
|
||||||
|
step: number;
|
||||||
|
maxStep: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditDocumentFormContainerStep = ({
|
||||||
|
title,
|
||||||
|
step,
|
||||||
|
maxStep,
|
||||||
|
}: EditDocumentFormContainerStepProps) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{title}{' '}
|
||||||
|
<span>
|
||||||
|
({step}/{maxStep})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-muted relative mt-4 h-[2px] rounded-md">
|
||||||
|
<div
|
||||||
|
className="bg-documenso absolute inset-y-0 left-0"
|
||||||
|
style={{
|
||||||
|
width: `${(100 / maxStep) * step}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditDocumentFormContainerActionsProps = {
|
||||||
|
canGoBack?: boolean;
|
||||||
|
canGoNext?: boolean;
|
||||||
|
goNextLabel?: string;
|
||||||
|
goBackLabel?: string;
|
||||||
|
onGoBackClick?: () => void;
|
||||||
|
onGoNextClick?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditDocumentFormContainerActions = ({
|
||||||
|
canGoBack = true,
|
||||||
|
canGoNext = true,
|
||||||
|
goNextLabel = 'Continue',
|
||||||
|
goBackLabel = 'Go Back',
|
||||||
|
onGoBackClick,
|
||||||
|
onGoNextClick,
|
||||||
|
loading,
|
||||||
|
disabled,
|
||||||
|
}: EditDocumentFormContainerActionsProps) => {
|
||||||
|
return (
|
||||||
|
<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"
|
||||||
|
disabled={disabled || loading || !canGoBack}
|
||||||
|
onClick={onGoBackClick}
|
||||||
|
>
|
||||||
|
{goBackLabel}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="bg-documenso flex-1"
|
||||||
|
size="lg"
|
||||||
|
disabled={disabled || loading || !canGoNext}
|
||||||
|
onClick={onGoNextClick}
|
||||||
|
>
|
||||||
|
{loading && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
||||||
|
{goNextLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { X } from 'lucide-react';
|
import { Trash } from 'lucide-react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Rnd } from 'react-rnd';
|
import { Rnd } from 'react-rnd';
|
||||||
|
|
||||||
@@ -53,8 +53,8 @@ export const FieldItem = ({
|
|||||||
|
|
||||||
const { height, width } = $page.getBoundingClientRect();
|
const { height, width } = $page.getBoundingClientRect();
|
||||||
|
|
||||||
const top = $page.offsetTop;
|
const top = $page.getBoundingClientRect().top + window.scrollY;
|
||||||
const left = $page.offsetLeft;
|
const left = $page.getBoundingClientRect().left + window.scrollX;
|
||||||
|
|
||||||
// X and Y are percentages of the page's height and width
|
// X and Y are percentages of the page's height and width
|
||||||
const pageX = (field.pageX / 100) * width + left;
|
const pageX = (field.pageX / 100) * width + left;
|
||||||
@@ -63,6 +63,24 @@ export const FieldItem = ({
|
|||||||
const pageHeight = (field.pageHeight / 100) * height;
|
const pageHeight = (field.pageHeight / 100) * height;
|
||||||
const pageWidth = (field.pageWidth / 100) * width;
|
const pageWidth = (field.pageWidth / 100) * width;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
structuredClone({
|
||||||
|
field,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
pageX: coords.pageX,
|
||||||
|
pageY: coords.pageY,
|
||||||
|
pageHeight: coords.pageHeight,
|
||||||
|
pageWidth: coords.pageWidth,
|
||||||
|
newPageX: pageX,
|
||||||
|
newPageY: pageY,
|
||||||
|
newPageHeight: pageHeight,
|
||||||
|
newPageWidth: pageWidth,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
setCoords({
|
setCoords({
|
||||||
pageX: pageX,
|
pageX: pageX,
|
||||||
pageY: pageY,
|
pageY: pageY,
|
||||||
@@ -92,10 +110,10 @@ export const FieldItem = ({
|
|||||||
key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth}
|
key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth}
|
||||||
className={cn('absolute z-20', {
|
className={cn('absolute z-20', {
|
||||||
'pointer-events-none': passive,
|
'pointer-events-none': passive,
|
||||||
'pointer-events-none opacity-75': disabled,
|
'pointer-events-none z-10 opacity-75': disabled,
|
||||||
})}
|
})}
|
||||||
minHeight={minHeight}
|
// minHeight={minHeight}
|
||||||
minWidth={minWidth}
|
// minWidth={minWidth}
|
||||||
default={{
|
default={{
|
||||||
x: coords.pageX,
|
x: coords.pageX,
|
||||||
y: coords.pageY,
|
y: coords.pageY,
|
||||||
@@ -116,16 +134,17 @@ export const FieldItem = ({
|
|||||||
>
|
>
|
||||||
{!disabled && (
|
{!disabled && (
|
||||||
<button
|
<button
|
||||||
className="bg-destructive absolute -right-2 -top-2 z-[9999] flex h-5 w-5 items-center justify-center rounded-full"
|
className="text-muted-foreground/50 hover:text-muted-foreground/80 absolute -right-2 -top-2 z-[9999] flex h-8 w-8 items-center justify-center rounded-full border bg-white shadow-[0_0_0_2px_theme(colors.gray.100/70%)]"
|
||||||
onClick={() => onRemove?.()}
|
onClick={() => onRemove?.()}
|
||||||
>
|
>
|
||||||
<X className="text-destructive-foreground h-4 w-4" />
|
<Trash className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
className={cn('hover:border-primary/50 h-full w-full bg-white', {
|
className={cn('h-full w-full bg-white', {
|
||||||
'border-primary hover:border-primary': active,
|
'border-primary': !disabled,
|
||||||
|
'border-primary/80': active,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<CardContent
|
<CardContent
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button, ButtonProps } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
|
||||||
|
export type SendDocumentActionDialogProps = ButtonProps & {
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SendDocumentActionDialog = ({
|
||||||
|
loading,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SendDocumentActionDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" className={className}>
|
||||||
|
{loading && <Loader className="text-documenso mr-2 h-5 w-5 animate-spin" />}
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-center text-lg font-semibold">Send Document</DialogTitle>
|
||||||
|
<DialogDescription className="text-center text-base">
|
||||||
|
You are about to send this document to the recipients. Are you sure you want to
|
||||||
|
continue?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4 flex items-center gap-x-4">
|
||||||
|
<Button
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 dark:focus-visible:ring-muted/80 flex-1 border-none bg-black/5 hover:bg-black/10 focus-visible:ring-black/10"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* We would use DialogAction here but it interrupts the submit action */}
|
||||||
|
<Button className={className} {...props}>
|
||||||
|
{loading && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -81,7 +81,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
className={cn('flex-col flex w-full gap-y-4', className)}
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
10
apps/web/src/helpers/getBoundingClientRect.ts
Normal file
10
apps/web/src/helpers/getBoundingClientRect.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const getBoundingClientRect = (element: HTMLElement) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
const { width, height } = rect;
|
||||||
|
|
||||||
|
const top = rect.top + window.scrollY;
|
||||||
|
const left = rect.left + window.scrollX;
|
||||||
|
|
||||||
|
return { top, left, width, height };
|
||||||
|
};
|
||||||
@@ -124,16 +124,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
document.document = await insertImageInPDF(
|
document.document = await insertImageInPDF(
|
||||||
document.document,
|
document.document,
|
||||||
signatureDataUrl,
|
signatureDataUrl,
|
||||||
field.positionX,
|
field.positionX.toNumber(),
|
||||||
field.positionY,
|
field.positionY.toNumber(),
|
||||||
field.page,
|
field.page,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
document.document = await insertTextInPDF(
|
document.document = await insertTextInPDF(
|
||||||
document.document,
|
document.document,
|
||||||
signatureText ?? '',
|
signatureText ?? '',
|
||||||
field.positionX,
|
field.positionX.toNumber(),
|
||||||
field.positionY,
|
field.positionY.toNumber(),
|
||||||
field.page,
|
field.page,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1346
package-lock.json
generated
1346
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
|||||||
export { render, renderAsync } from '@react-email/components';
|
export {};
|
||||||
|
|||||||
50
packages/email/mailer.ts
Normal file
50
packages/email/mailer.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { createTransport } from 'nodemailer';
|
||||||
|
|
||||||
|
import { MailChannelsTransport } from './transports/mailchannels';
|
||||||
|
|
||||||
|
const getTransport = () => {
|
||||||
|
const transport = process.env.NEXT_PRIVATE_SMTP_TRANSPORT ?? 'smtp-auth';
|
||||||
|
|
||||||
|
if (transport === 'mailchannels') {
|
||||||
|
return createTransport(
|
||||||
|
MailChannelsTransport.makeTransport({
|
||||||
|
apiKey: process.env.NEXT_PRIVATE_MAILCHANNELS_API_KEY,
|
||||||
|
endpoint: process.env.NEXT_PRIVATE_MAILCHANNELS_ENDPOINT,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transport === 'smtp-api') {
|
||||||
|
if (!process.env.NEXT_PRIVATE_SMTP_HOST || !process.env.NEXT_PRIVATE_SMTP_APIKEY) {
|
||||||
|
throw new Error(
|
||||||
|
'SMTP API transport requires NEXT_PRIVATE_SMTP_HOST and NEXT_PRIVATE_SMTP_APIKEY',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createTransport({
|
||||||
|
host: process.env.NEXT_PRIVATE_SMTP_HOST,
|
||||||
|
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
|
||||||
|
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
|
||||||
|
auth: {
|
||||||
|
user: process.env.NEXT_PRIVATE_SMTP_APIKEY_USER ?? 'apikey',
|
||||||
|
pass: process.env.NEXT_PRIVATE_SMTP_APIKEY ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.NEXT_PRIVATE_SMTP_HOST) {
|
||||||
|
throw new Error('SMTP transport requires NEXT_PRIVATE_SMTP_HOST');
|
||||||
|
}
|
||||||
|
|
||||||
|
return createTransport({
|
||||||
|
host: process.env.NEXT_PRIVATE_SMTP_HOST,
|
||||||
|
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
|
||||||
|
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
|
||||||
|
auth: {
|
||||||
|
user: process.env.NEXT_PRIVATE_SMTP_USERNAME ?? '',
|
||||||
|
pass: process.env.NEXT_PRIVATE_SMTP_PASSWORD ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mailer = getTransport();
|
||||||
@@ -5,18 +5,26 @@
|
|||||||
"types": "./index.ts",
|
"types": "./index.ts",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"files": [
|
"files": [
|
||||||
"templates/"
|
"templates/",
|
||||||
|
"transports/",
|
||||||
|
"mailer.ts",
|
||||||
|
"render.ts",
|
||||||
|
"index.ts"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "email dev --port 3002 --dir templates"
|
"dev": "email dev --port 3002 --dir templates",
|
||||||
|
"worker:test": "tsup worker/index.ts --format esm"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/tsconfig": "*",
|
"@documenso/tsconfig": "*",
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"@react-email/components": "^0.0.7"
|
"@react-email/components": "^0.0.7",
|
||||||
|
"nodemailer": "^6.9.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"react-email": "^1.9.4"
|
"@types/nodemailer": "^6.4.8",
|
||||||
|
"react-email": "^1.9.4",
|
||||||
|
"tsup": "^7.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/email/render.ts
Normal file
1
packages/email/render.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { render, renderAsync } from '@react-email/components';
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Button,
|
|
||||||
Container,
|
Container,
|
||||||
Head,
|
Head,
|
||||||
Html,
|
Html,
|
||||||
|
|||||||
154
packages/email/transports/mailchannels.ts
Normal file
154
packages/email/transports/mailchannels.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { SentMessageInfo, Transport } from 'nodemailer';
|
||||||
|
import type { Address } from 'nodemailer/lib/mailer';
|
||||||
|
import type MailMessage from 'nodemailer/lib/mailer/mail-message';
|
||||||
|
|
||||||
|
const VERSION = '1.0.0';
|
||||||
|
|
||||||
|
type NodeMailerAddress = string | Address | Array<string | Address> | undefined;
|
||||||
|
|
||||||
|
interface MailChannelsAddress {
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MailChannelsTransportOptions {
|
||||||
|
apiKey: string;
|
||||||
|
endpoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transport for sending email through MailChannels via Cloudflare Workers.
|
||||||
|
*
|
||||||
|
* Optionally allows specifying a custom endpoint and API key so you can setup a worker
|
||||||
|
* to proxy requests to MailChannels with added security.
|
||||||
|
*
|
||||||
|
* @see https://blog.cloudflare.com/sending-email-from-workers-with-mailchannels/
|
||||||
|
*/
|
||||||
|
export class MailChannelsTransport implements Transport<SentMessageInfo> {
|
||||||
|
public name = 'CloudflareMailTransport';
|
||||||
|
public version = VERSION;
|
||||||
|
|
||||||
|
private _options: MailChannelsTransportOptions;
|
||||||
|
|
||||||
|
public static makeTransport(options: Partial<MailChannelsTransportOptions>) {
|
||||||
|
return new MailChannelsTransport(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(options: Partial<MailChannelsTransportOptions>) {
|
||||||
|
const { apiKey = '', endpoint = 'https://api.mailchannels.net/tx/v1/send' } = options;
|
||||||
|
|
||||||
|
this._options = {
|
||||||
|
apiKey,
|
||||||
|
endpoint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public send(mail: MailMessage, callback: (_err: Error | null, _info: SentMessageInfo) => void) {
|
||||||
|
if (!mail.data.to || !mail.data.from) {
|
||||||
|
return callback(new Error('Missing required fields "to" or "from"'), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailTo = this.toMailChannelsAddresses(mail.data.to);
|
||||||
|
const mailCc = this.toMailChannelsAddresses(mail.data.cc);
|
||||||
|
const mailBcc = this.toMailChannelsAddresses(mail.data.bcc);
|
||||||
|
|
||||||
|
const from: MailChannelsAddress =
|
||||||
|
typeof mail.data.from === 'string'
|
||||||
|
? { email: mail.data.from }
|
||||||
|
: {
|
||||||
|
email: mail.data.from?.address,
|
||||||
|
name: mail.data.from?.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestHeaders: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this._options.apiKey) {
|
||||||
|
requestHeaders['X-Auth-Token'] = this._options.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(this._options.endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: from,
|
||||||
|
subject: mail.data.subject,
|
||||||
|
personalizations: [
|
||||||
|
{
|
||||||
|
to: mailTo,
|
||||||
|
cc: mailCc.length > 0 ? mailCc : undefined,
|
||||||
|
bcc: mailBcc.length > 0 ? mailBcc : undefined,
|
||||||
|
dkim_domain: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN || undefined,
|
||||||
|
dkim_selector: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR || undefined,
|
||||||
|
dkim_private_key: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY || undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text/plain',
|
||||||
|
value: mail.data.text?.toString('utf-8') ?? '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text/html',
|
||||||
|
value: mail.data.html?.toString('utf-8') ?? '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status >= 200 && res.status <= 299) {
|
||||||
|
return callback(null, {
|
||||||
|
messageId: '',
|
||||||
|
envelope: {
|
||||||
|
from: mail.data.from,
|
||||||
|
to: mail.data.to,
|
||||||
|
},
|
||||||
|
accepted: mail.data.to,
|
||||||
|
rejected: [],
|
||||||
|
pending: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json().then((data) => {
|
||||||
|
return callback(new Error(`MailChannels error: ${data.message}`), null);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
return callback(err, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a nodemailer address(s) to an array of MailChannel compatible address.
|
||||||
|
*/
|
||||||
|
private toMailChannelsAddresses(address: NodeMailerAddress): Array<MailChannelsAddress> {
|
||||||
|
if (!address) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof address === 'string') {
|
||||||
|
return [{ email: address }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(address)) {
|
||||||
|
return address.map((address) => {
|
||||||
|
if (typeof address === 'string') {
|
||||||
|
return { email: address };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: address.address,
|
||||||
|
name: address.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
email: address.address,
|
||||||
|
name: address.name,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^5.59.2",
|
"@typescript-eslint/eslint-plugin": "^5.59.2",
|
||||||
"@typescript-eslint/parser": "^5.59.2",
|
"@typescript-eslint/parser": "^5.59.2",
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
"eslint-config-next": "^13.4.1",
|
"eslint-config-next": "^13.4.9",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-react": "^7.32.2",
|
"eslint-plugin-react": "^7.32.2",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@documenso/email": "*",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"@next-auth/prisma-adapter": "^1.0.6",
|
"@next-auth/prisma-adapter": "^1.0.6",
|
||||||
@@ -20,8 +21,9 @@
|
|||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"next": "13.4.1",
|
"next": "13.4.9",
|
||||||
"next-auth": "^4.22.1",
|
"next-auth": "^4.22.1",
|
||||||
|
"react": "18.2.0",
|
||||||
"stripe": "^12.7.0"
|
"stripe": "^12.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
94
packages/lib/server-only/document/send-document.tsx
Normal file
94
packages/lib/server-only/document/send-document.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { render } from '@documenso/email/render';
|
||||||
|
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export interface SendDocumentOptions {
|
||||||
|
documentId: number;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const document = await prisma.document.findUnique({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.Recipient.length === 0) {
|
||||||
|
throw new Error('Document has no recipients');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
throw new Error('Can not send completed document');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
document.Recipient.map(async (recipient) => {
|
||||||
|
const { email, name } = recipient;
|
||||||
|
|
||||||
|
if (recipient.sendStatus === SendStatus.SENT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = createElement(DocumentInviteEmailTemplate, {
|
||||||
|
documentName: document.title,
|
||||||
|
assetBaseUrl: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
||||||
|
inviterName: user.name || undefined,
|
||||||
|
inviterEmail: user.email,
|
||||||
|
signDocumentLink: 'https://example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: email,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||||
|
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||||
|
},
|
||||||
|
subject: 'Please sign this document',
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.recipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipient.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const updatedDocument = await prisma.document.update({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: DocumentStatus.PENDING,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedDocument;
|
||||||
|
};
|
||||||
@@ -48,23 +48,21 @@ export const setFieldsForDocument = async ({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const linkedFields = fields.map((field) => {
|
const linkedFields = fields
|
||||||
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
.map((field) => {
|
||||||
|
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
...existing,
|
...existing,
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
|
.filter((field) => {
|
||||||
for (const field of linkedFields) {
|
return (
|
||||||
if (
|
field.Recipient?.sendStatus !== SendStatus.SENT &&
|
||||||
field.Recipient?.sendStatus === SendStatus.SENT ||
|
field.Recipient?.signingStatus !== SigningStatus.SIGNED
|
||||||
field.Recipient?.signingStatus === SigningStatus.SIGNED
|
);
|
||||||
) {
|
});
|
||||||
throw new Error('Cannot modify fields after sending');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistedFields = await prisma.$transaction(
|
const persistedFields = await prisma.$transaction(
|
||||||
linkedFields.map((field) =>
|
linkedFields.map((field) =>
|
||||||
|
|||||||
@@ -43,26 +43,23 @@ export const setRecipientsForDocument = async ({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const linkedRecipients = recipients.map((recipient) => {
|
const linkedRecipients = recipients
|
||||||
const existing = existingRecipients.find(
|
.map((recipient) => {
|
||||||
(existingRecipient) =>
|
const existing = existingRecipients.find(
|
||||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
(existingRecipient) =>
|
||||||
);
|
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...recipient,
|
...recipient,
|
||||||
...existing,
|
...existing,
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
|
.filter((recipient) => {
|
||||||
for (const recipient of linkedRecipients) {
|
return (
|
||||||
if (
|
recipient.sendStatus !== SendStatus.SENT && recipient.signingStatus !== SigningStatus.SIGNED
|
||||||
recipient.sendStatus === SendStatus.SENT ||
|
);
|
||||||
recipient.signingStatus === SigningStatus.SIGNED
|
});
|
||||||
) {
|
|
||||||
throw new Error('Cannot modify recipients after sending');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistedRecipients = await prisma.$transaction(
|
const persistedRecipients = await prisma.$transaction(
|
||||||
linkedRecipients.map((recipient) =>
|
linkedRecipients.map((recipient) =>
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ module.exports = {
|
|||||||
DEFAULT: 'hsl(var(--card))',
|
DEFAULT: 'hsl(var(--card))',
|
||||||
foreground: 'hsl(var(--card-foreground))',
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
},
|
},
|
||||||
|
widget: {
|
||||||
|
DEFAULT: 'hsl(var(--widget))',
|
||||||
|
// foreground: 'hsl(var(--widget-foreground))',
|
||||||
|
},
|
||||||
documenso: {
|
documenso: {
|
||||||
DEFAULT: '#A2E771',
|
DEFAULT: '#A2E771',
|
||||||
50: '#FFFFFF',
|
50: '#FFFFFF',
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
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 { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||||
|
|
||||||
import { authenticatedProcedure, router } from '../trpc';
|
import { authenticatedProcedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
|
ZSendDocumentMutationSchema,
|
||||||
ZSetFieldsForDocumentMutationSchema,
|
ZSetFieldsForDocumentMutationSchema,
|
||||||
ZSetRecipientsForDocumentMutationSchema,
|
ZSetRecipientsForDocumentMutationSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
@@ -52,4 +54,24 @@ export const documentRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
sendDocument: authenticatedProcedure
|
||||||
|
.input(ZSendDocumentMutationSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { documentId } = input;
|
||||||
|
|
||||||
|
return await sendDocument({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to send this document. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,3 +36,9 @@ export const ZSetFieldsForDocumentMutationSchema = z.object({
|
|||||||
export type TSetFieldsForDocumentMutationSchema = z.infer<
|
export type TSetFieldsForDocumentMutationSchema = z.infer<
|
||||||
typeof ZSetFieldsForDocumentMutationSchema
|
typeof ZSetFieldsForDocumentMutationSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export const ZSendDocumentMutationSchema = z.object({
|
||||||
|
documentId: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSendDocumentMutationSchema = z.infer<typeof ZSendDocumentMutationSchema>;
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
"preserveWatchOutput": true,
|
"preserveWatchOutput": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "ES2018"
|
"target": "ES2018",
|
||||||
|
"types": ["@documenso/tsconfig/process-env.d.ts"]
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
36
packages/tsconfig/process-env.d.ts
vendored
Normal file
36
packages/tsconfig/process-env.d.ts
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
declare namespace NodeJS {
|
||||||
|
export interface ProcessEnv {
|
||||||
|
NEXT_PUBLIC_SITE_URL?: string;
|
||||||
|
|
||||||
|
NEXT_PRIVATE_DATABASE_URL: string;
|
||||||
|
|
||||||
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||||
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
||||||
|
|
||||||
|
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||||
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||||
|
|
||||||
|
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: string;
|
||||||
|
|
||||||
|
NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'smtp-auth' | 'smtp-api';
|
||||||
|
|
||||||
|
NEXT_PRIVATE_MAILCHANNELS_API_KEY?: string;
|
||||||
|
NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN?: string;
|
||||||
|
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR?: string;
|
||||||
|
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY?: string;
|
||||||
|
NEXT_PRIVATE_MAILCHANNELS_ENDPOINT?: string;
|
||||||
|
|
||||||
|
NEXT_PRIVATE_SMTP_HOST?: string;
|
||||||
|
NEXT_PRIVATE_SMTP_PORT?: string;
|
||||||
|
NEXT_PRIVATE_SMTP_USERNAME?: string;
|
||||||
|
NEXT_PRIVATE_SMTP_PASSWORD?: string;
|
||||||
|
|
||||||
|
NEXT_PRIVATE_SMTP_APIKEY_USER?: string;
|
||||||
|
NEXT_PRIVATE_SMTP_APIKEY?: string;
|
||||||
|
|
||||||
|
NEXT_PRIVATE_SMTP_SECURE?: string;
|
||||||
|
|
||||||
|
NEXT_PRIVATE_SMTP_FROM_NAME?: string;
|
||||||
|
NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,15 +16,17 @@ const TooltipContent = React.forwardRef<
|
|||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
<TooltipPrimitive.Content
|
<TooltipPrimitive.Portal>
|
||||||
ref={ref}
|
<TooltipPrimitive.Content
|
||||||
sideOffset={sideOffset}
|
ref={ref}
|
||||||
className={cn(
|
sideOffset={sideOffset}
|
||||||
'bg-popover text-popover-foreground animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
|
className={cn(
|
||||||
className,
|
'bg-popover text-popover-foreground animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
|
||||||
)}
|
className,
|
||||||
{...props}
|
)}
|
||||||
/>
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
));
|
));
|
||||||
|
|
||||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|||||||
17
turbo.json
17
turbo.json
@@ -18,6 +18,21 @@
|
|||||||
"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_SMTP_TRANSPORT",
|
||||||
|
"NEXT_PRIVATE_MAILCHANNELS_API_KEY",
|
||||||
|
"NEXT_PRIVATE_MAILCHANNELS_ENDPOINT",
|
||||||
|
"NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN",
|
||||||
|
"NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR",
|
||||||
|
"NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY",
|
||||||
|
"NEXT_PRIVATE_SMTP_HOST",
|
||||||
|
"NEXT_PRIVATE_SMTP_PORT",
|
||||||
|
"NEXT_PRIVATE_SMTP_USERNAME",
|
||||||
|
"NEXT_PRIVATE_SMTP_PASSWORD",
|
||||||
|
"NEXT_PRIVATE_SMTP_APIKEY_USER",
|
||||||
|
"NEXT_PRIVATE_SMTP_APIKEY",
|
||||||
|
"NEXT_PRIVATE_SMTP_SECURE",
|
||||||
|
"NEXT_PRIVATE_SMTP_FROM_NAME",
|
||||||
|
"NEXT_PRIVATE_SMTP_FROM_ADDRESS"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user