diff --git a/.env.example b/.env.example index fea246621..3ce57722b 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,56 @@ +# [[AUTH]] NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="secret" +# [[APP]] NEXT_PUBLIC_SITE_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" +# [[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_YEARLY_PRICE_ID= -NEXT_PRIVATE_STRIPE_API_KEY= -NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= - +# [[FEATURES]] NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false # This is only required for the marketing site +# [[REDIS]] NEXT_PRIVATE_REDIS_URL= NEXT_PRIVATE_REDIS_TOKEN= diff --git a/apps/marketing/package.json b/apps/marketing/package.json index e34c66b99..523a23a90 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -18,7 +18,7 @@ "framer-motion": "^10.12.8", "lucide-react": "^0.214.0", "micro": "^10.0.1", - "next": "13.4.1", + "next": "13.4.9", "next-auth": "^4.22.1", "next-plausible": "^3.7.2", "perfect-freehand": "^1.2.0", diff --git a/apps/web/next.config.js b/apps/web/next.config.js index b57b41780..09760f806 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -7,9 +7,23 @@ const { parsed: env } = require('dotenv').config({ /** @type {import('next').NextConfig} */ const config = { + experimental: { + serverActions: true, + }, reactStrictMode: true, - transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'], + transpilePackages: [ + '@documenso/lib', + '@documenso/prisma', + '@documenso/trpc', + '@documenso/ui', + '@documenso/email', + ], env, + modularizeImports: { + 'lucide-react': { + transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}', + }, + }, }; module.exports = config; diff --git a/apps/web/package.json b/apps/web/package.json index d493b92d9..32d0d61b3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,7 @@ "lucide-react": "^0.214.0", "micro": "^10.0.1", "nanoid": "^4.0.2", - "next": "13.4.1", + "next": "13.4.9", "next-auth": "^4.22.1", "next-plausible": "^3.7.2", "next-themes": "^0.2.1", diff --git a/apps/web/public/static/clock.png b/apps/web/public/static/clock.png new file mode 100644 index 000000000..ce8ee6481 Binary files /dev/null and b/apps/web/public/static/clock.png differ diff --git a/apps/web/public/static/completed.png b/apps/web/public/static/completed.png new file mode 100644 index 000000000..11a10f445 Binary files /dev/null and b/apps/web/public/static/completed.png differ diff --git a/apps/web/public/static/document.png b/apps/web/public/static/document.png new file mode 100644 index 000000000..8baa22639 Binary files /dev/null and b/apps/web/public/static/document.png differ diff --git a/apps/web/public/static/download.png b/apps/web/public/static/download.png new file mode 100644 index 000000000..c376d6051 Binary files /dev/null and b/apps/web/public/static/download.png differ diff --git a/apps/web/public/static/logo.png b/apps/web/public/static/logo.png new file mode 100644 index 000000000..4813aaaef Binary files /dev/null and b/apps/web/public/static/logo.png differ diff --git a/apps/web/public/static/review.png b/apps/web/public/static/review.png new file mode 100644 index 000000000..84bec4f62 Binary files /dev/null and b/apps/web/public/static/review.png differ diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx new file mode 100644 index 000000000..b79c46671 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -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: () => ( +
+ + +

Loading document...

+
+ ), +}); + +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 ( +
+ + + + + + +
+ {step === 'signers' && ( + + )} + + {step === 'fields' && ( + + )} + + {step === 'subject' && ( + + )} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx b/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx index b2008c921..5daa07e9e 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx @@ -16,7 +16,7 @@ export type LoadablePDFCard = PDFViewerProps & { const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), { ssr: false, loading: () => ( -
+

Loading document...

diff --git a/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx b/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx index e68b149dc..e97489a6e 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx @@ -4,7 +4,7 @@ import { ChevronLeft, Loader } from 'lucide-react'; export default function Loading() { return ( -
+
Documents @@ -13,15 +13,15 @@ export default function Loading() { Loading Document...
-
-
+
+

Loading document...

-
+
); diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index 7f3b76909..94fb8ad96 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -1,14 +1,15 @@ import Link from 'next/link'; 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 { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; -import { EditDocumentForm } from '~/components/forms/edit-document'; +import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; +import { DocumentStatus } from '~/components/formatter/document-status'; export type DocumentPageProps = { params: { @@ -61,6 +62,18 @@ export default async function DocumentPage({ params }: DocumentPageProps) { {document.title} +
+ + + {recipients.length > 0 && ( +
+ + + {recipients.length} Recipient(s) +
+ )} +
+ + + + Documents + + +

+ Loading Document... +

+
+ ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 9ce82a4c0..9c60bbadf 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import { Inter } from 'next/font/google'; import { TrpcProvider } from '@documenso/trpc/react'; import { Toaster } from '@documenso/ui/primitives/toaster'; +import { TooltipProvider } from '@documenso/ui/primitives/tooltip'; import { ThemeProvider } from '~/providers/next-theme'; import { PlausibleProvider } from '~/providers/plausible'; @@ -47,7 +48,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - {children} + + {children} + diff --git a/apps/web/src/components/(dashboard)/document-dropzone/document-dropzone.tsx b/apps/web/src/components/(dashboard)/document-dropzone/document-dropzone.tsx index 3688bdfca..0cc8dd22a 100644 --- a/apps/web/src/components/(dashboard)/document-dropzone/document-dropzone.tsx +++ b/apps/web/src/components/(dashboard)/document-dropzone/document-dropzone.tsx @@ -2,7 +2,6 @@ import { Variants, motion } from 'framer-motion'; import { Plus } from 'lucide-react'; -import { useTheme } from 'next-themes'; import { useDropzone } from 'react-dropzone'; import { cn } from '@documenso/ui/lib/utils'; @@ -92,8 +91,6 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo }, }); - const { theme } = useTheme(); - return ( - + {/* */}
@@ -127,7 +123,7 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
diff --git a/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx b/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx index 9e5b1db0e..f59d42096 100644 --- a/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx +++ b/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx @@ -1,6 +1,4 @@ -import React from 'react'; - -import { LucideIcon } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import { cn } from '@documenso/ui/lib/utils'; @@ -15,7 +13,7 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr return (
diff --git a/apps/web/src/components/formatter/document-status.tsx b/apps/web/src/components/formatter/document-status.tsx index 54feb3bd5..fbe1e1a3b 100644 --- a/apps/web/src/components/formatter/document-status.tsx +++ b/apps/web/src/components/formatter/document-status.tsx @@ -1,6 +1,7 @@ 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 { cn } from '@documenso/ui/lib/utils'; @@ -31,14 +32,24 @@ const FRIENDLY_STATUS_MAP: Record = { export type DocumentStatusProps = HTMLAttributes & { 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]; return ( - + {label} ); diff --git a/apps/web/src/components/forms/edit-document.tsx b/apps/web/src/components/forms/edit-document.tsx deleted file mode 100644 index 482c63fae..000000000 --- a/apps/web/src/components/forms/edit-document.tsx +++ /dev/null @@ -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: () => ( -
- - -

Loading document...

-
- ), -}); - -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({ - 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 ( -
- - - - - - -
-
- {step === 0 && ( - - )} - - {step === 1 && ( - - )} - - {step === 2 && ( - - )} - -
-

- Add Signers ({step + 1}/{MAX_STEP + 1}) -

- -
-
-
- -
- - - {step < MAX_STEP && ( - - )} - - {step === MAX_STEP && ( - - )} -
-
- -
-
- ); -}; diff --git a/apps/web/src/components/forms/edit-document/add-fields.action.ts b/apps/web/src/components/forms/edit-document/add-fields.action.ts new file mode 100644 index 000000000..022c593de --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-fields.action.ts @@ -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, + })), + }); +}; diff --git a/apps/web/src/components/forms/edit-document/add-fields.tsx b/apps/web/src/components/forms/edit-document/add-fields.tsx index cf48b1f1d..889cdbf29 100644 --- a/apps/web/src/components/forms/edit-document/add-fields.tsx +++ b/apps/web/src/components/forms/edit-document/add-fields.tsx @@ -3,12 +3,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; 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 { 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 { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -20,11 +21,23 @@ import { CommandItem, } from '@documenso/ui/primitives/command'; 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 { 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 { FRIENDLY_FIELD_TYPE, TEditDocumentFormSchema } from './types'; +import { FRIENDLY_FIELD_TYPE } from './types'; const fontCaveat = Caveat({ weight: ['500'], @@ -40,31 +53,58 @@ const MIN_HEIGHT_PX = 60; const MIN_WIDTH_PX = 200; export type AddFieldsFormProps = { - className?: string; - control: Control; - watch: UseFormWatch; - errors: FieldErrors; - isSubmitting: boolean; + recipients: Recipient[]; + fields: Field[]; + document: Document; + onContinue?: () => void; + onGoBack?: () => void; }; export const AddFieldsFormPartial = ({ - className, - control: control, - watch, - errors: _errors, - isSubmitting: _isSubmitting, + recipients, + fields, + document, + onContinue, + onGoBack, }: AddFieldsFormProps) => { - const signers = watch('signers'); - const fields = watch('fields'); + const { toast } = useToast(); + const router = useRouter(); - const { append, remove, update } = useFieldArray({ + const { + control, + handleSubmit, + formState: { isSubmitting }, + } = useForm({ + 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, name: 'fields', }); - const [selectedSigner, setSelectedSigner] = useState(() => signers[0]); - const [selectedField, setSelectedField] = useState(null); + const [selectedSigner, setSelectedSigner] = useState(null); + + const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT; const [visible, setVisible] = useState(false); const [coords, setCoords] = useState({ @@ -77,14 +117,60 @@ export const AddFieldsFormPartial = ({ 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)) { - return false; + return null; } const target = event.target; + const $page = - target.closest(PDF_VIEWER_PAGE_SELECTOR) ?? target.querySelector(PDF_VIEWER_PAGE_SELECTOR); + target.closest(PDF_VIEWER_PAGE_SELECTOR) ?? + target.querySelector(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) { return false; @@ -121,28 +207,17 @@ export const AddFieldsFormPartial = ({ const onMouseClick = useCallback( (event: MouseEvent) => { - if (!selectedField) { + if (!selectedField || !selectedSigner) { return; } - if (!(event.target instanceof HTMLElement)) { - return; - } - - const target = event.target; - - const $page = - target.closest(PDF_VIEWER_PAGE_SELECTOR) ?? - target.querySelector(PDF_VIEWER_PAGE_SELECTOR); + const $page = getPage(event); if (!$page || !isWithinPageBounds(event)) { return; } - const { height, width } = $page.getBoundingClientRect(); - - const top = $page.offsetTop; - const left = $page.offsetLeft; + const { top, left, height, width } = getBoundingClientRect($page); const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10); @@ -172,14 +247,14 @@ export const AddFieldsFormPartial = ({ setVisible(false); setSelectedField(null); }, - [append, isWithinPageBounds, selectedField, selectedSigner.email], + [append, isWithinPageBounds, selectedField, selectedSigner], ); const onFieldResize = useCallback( (node: HTMLElement, index: number) => { - const field = fields[index]; + const field = localFields[index]; - const $page = document.querySelector( + const $page = window.document.querySelector( `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, ); @@ -187,71 +262,69 @@ export const AddFieldsFormPartial = ({ return; } - const { height: pageHeight, width: pageWidth } = $page.getBoundingClientRect(); - - const pageTop = $page.offsetTop; - const pageLeft = $page.offsetLeft; - - let { top: nodeTop, left: nodeLeft } = node.getBoundingClientRect(); - const { height, width } = node.getBoundingClientRect(); - - nodeTop += window.scrollY; - nodeLeft += window.scrollX; - - // Calculate width and height as a percentage of the page width and height - const newWidth = (width / pageWidth) * 100; - const newHeight = (height / pageHeight) * 100; - - // Calculate the new position as a percentage of the page width and height - const newX = ((nodeLeft - pageLeft) / pageWidth) * 100; - const newY = ((nodeTop - pageTop) / pageHeight) * 100; + const { + x: pageX, + y: pageY, + width: pageWidth, + height: pageHeight, + } = getFieldPosition($page, node); update(index, { ...field, - pageX: newX, - pageY: newY, - pageWidth: newWidth, - pageHeight: newHeight, + pageX, + pageY, + pageWidth, + pageHeight, }); }, - [fields, update], + [localFields, update], ); const onFieldMove = useCallback( (node: HTMLElement, index: number) => { - const field = fields[index]; + const field = localFields[index]; - const $page = document.querySelector( + const $page = window.document.querySelector( `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, ); - if (!$page || !($page instanceof HTMLElement)) { + if (!$page) { return; } - const { height: pageHeight, width: pageWidth } = $page.getBoundingClientRect(); - - const pageTop = $page.offsetTop; - const pageLeft = $page.offsetLeft; - - let { top: nodeTop, left: nodeLeft } = node.getBoundingClientRect(); - - 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; + const { x: pageX, y: pageY } = getFieldPosition($page, node); update(index, { ...field, - pageX: newX, - pageY: newY, + pageX, + 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(() => { if (selectedField) { window.addEventListener('mousemove', onMouseMove); @@ -265,7 +338,7 @@ export const AddFieldsFormPartial = ({ }, [onMouseClick, onMouseMove, selectedField]); useEffect(() => { - const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR); + const $page = window.document.querySelector(PDF_VIEWER_PAGE_SELECTOR); if (!$page) { return; @@ -279,183 +352,226 @@ export const AddFieldsFormPartial = ({ }; }, []); + useEffect(() => { + setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]); + }, [recipients]); + return ( -
- {selectedField && visible && ( - - - {FRIENDLY_FIELD_TYPE[selectedField]} - - - )} - - {fields.map((field, index) => ( - onFieldResize(options, index)} - onMove={(options) => onFieldMove(options, index)} - onRemove={() => remove(index)} - /> - ))} - -

Add Fields

- -

- Add all relevant fields for each recipient. -

- -
- - - - - - - - - - - - - {signers.map((signer, index) => ( - setSelectedSigner(signer)}> - - {signer.name && ( - - {signer.name} ({signer.email}) - - )} - - {!signer.name && {signer.email}} - - ))} - - - - - -
-
- + )} - + + + + + + - + + {recipients.map((recipient, index) => ( + setSelectedSigner(recipient)} + > + {recipient.sendStatus !== SendStatus.SENT ? ( + + ) : ( + + + + + + This document has already been sent to this recipient. You can no longer + edit this recipient. + + + )} + + {recipient.name && ( + + {recipient.name} ({recipient.email}) + + )} + + {!recipient.name && ( + + {recipient.email} + + )} + + ))} + + + + + +
+
+ + + + + + + +
+
-
-
+ + + + + + onFormSubmit()} + onGoBackClick={onGoBack} + /> + + ); }; diff --git a/apps/web/src/components/forms/edit-document/add-fields.types.ts b/apps/web/src/components/forms/edit-document/add-fields.types.ts new file mode 100644 index 000000000..e7a509632 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-fields.types.ts @@ -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; diff --git a/apps/web/src/components/forms/edit-document/add-signers.action.ts b/apps/web/src/components/forms/edit-document/add-signers.action.ts new file mode 100644 index 000000000..a37c12af8 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-signers.action.ts @@ -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, + })), + }); +}; diff --git a/apps/web/src/components/forms/edit-document/add-signers.tsx b/apps/web/src/components/forms/edit-document/add-signers.tsx index 88427eee2..08cdeb4cb 100644 --- a/apps/web/src/components/forms/edit-document/add-signers.tsx +++ b/apps/web/src/components/forms/edit-document/add-signers.tsx @@ -1,35 +1,76 @@ 'use client'; -import React from 'react'; +import React, { useId } from 'react'; + +import { useRouter } from 'next/navigation'; import { AnimatePresence, motion } from 'framer-motion'; import { Plus, Trash } from 'lucide-react'; import { nanoid } from 'nanoid'; -import { Control, Controller, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form'; +import { 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 { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; 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 = { - className?: string; - control: Control; - watch: UseFormWatch; - errors: FieldErrors; - isSubmitting: boolean; + recipients: Recipient[]; + fields: Field[]; + document: Document; + onContinue?: () => void; + onGoBack?: () => void; }; export const AddSignersFormPartial = ({ - className, - control, - errors, - isSubmitting, + recipients, + fields: _fields, + document: document, + onContinue, + onGoBack, }: AddSignersFormProps) => { + const { toast } = useToast(); + const router = useRouter(); + + const initialId = useId(); + + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + 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 { append: appendSigner, fields: signers, @@ -39,10 +80,15 @@ export const AddSignersFormPartial = ({ name: 'signers', }); - const { remove: removeField, fields: fields } = useFieldArray({ - name: 'fields', - control, - }); + const hasBeenSentToRecipientId = (id?: number) => { + if (!id) { + return false; + } + + return recipients.some( + (recipient) => recipient.id === id && recipient.sendStatus === SendStatus.SENT, + ); + }; const onAddSigner = () => { appendSigner({ @@ -55,17 +101,17 @@ export const AddSignersFormPartial = ({ const onRemoveSigner = (index: number) => { 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); - - const fieldsToRemove: number[] = []; - - fields.forEach((field, fieldIndex) => { - if (field.signerEmail === signer.email) { - fieldsToRemove.push(fieldIndex); - } - }); - - removeField(fieldsToRemove); }; const onKeyDown = (event: React.KeyboardEvent) => { @@ -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 ( -
-

Add Signers

- -

- Add the people who will sign the document. -

- -
- -
-
+ + +
{signers.map((signer, index) => ( - +
-
-
+ + + + + + onFormSubmit()} + onGoBackClick={onGoBack} + /> + + ); }; diff --git a/apps/web/src/components/forms/edit-document/add-signers.types.ts b/apps/web/src/components/forms/edit-document/add-signers.types.ts new file mode 100644 index 000000000..2b1418934 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-signers.types.ts @@ -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; diff --git a/apps/web/src/components/forms/edit-document/add-subject.action.ts b/apps/web/src/components/forms/edit-document/add-subject.action.ts new file mode 100644 index 000000000..3beb166ca --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-subject.action.ts @@ -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, + }); +}; diff --git a/apps/web/src/components/forms/edit-document/add-subject.tsx b/apps/web/src/components/forms/edit-document/add-subject.tsx index 2aedb0127..c5c2bdd83 100644 --- a/apps/web/src/components/forms/edit-document/add-subject.tsx +++ b/apps/web/src/components/forms/edit-document/add-subject.tsx @@ -1,111 +1,169 @@ '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 { Label } from '@documenso/ui/primitives/label'; import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; 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 = { - className?: string; - control: Control; - watch: UseFormWatch; - errors: FieldErrors; - isSubmitting: boolean; + recipients: Recipient[]; + fields: Field[]; + document: Document; + onContinue?: () => void; + onGoBack?: () => void; }; export const AddSubjectFormPartial = ({ - className, - control, - errors, - isSubmitting, + recipients: _recipients, + fields: _fields, + document, + onContinue, + onGoBack, }: AddSubjectFormProps) => { + const { toast } = useToast(); + const router = useRouter(); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + 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 ( -
-

Add Subject

+ + +
+
+
+ -

- Add the subject and message you wish to send to signers. -

- -
- -
-
- - - ( - )} - /> - -
+ +
-
- +
+ - (