diff --git a/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx b/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx index 06bfd5ced..f350a7e01 100644 --- a/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx +++ b/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx @@ -5,7 +5,7 @@ import React, { useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Info, Loader } from 'lucide-react'; +import { Info } from 'lucide-react'; import { usePlausible } from 'next-plausible'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -85,7 +85,7 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog }; return ( - + !isSubmitting && setOpen(value)}> {children} @@ -97,50 +97,49 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog -
- {params?.get('cancelled') === 'true' && ( -
-
-
- -
-
-

- You have cancelled the payment process. If you didn't mean to do this, please - try again. -

+ +
+ {params?.get('cancelled') === 'true' && ( +
+
+
+ +
+
+

+ You have cancelled the payment process. If you didn't mean to do this, please + try again. +

+
+ )} + +
+ + + + +
- )} -
- +
+ - + - -
+ +
-
- - - - - -
- - + +
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index a434987a6..e1c9a79e1 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -13,6 +13,11 @@ import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/ad import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types'; import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject'; import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types'; +import { + DocumentFlowFormContainer, + DocumentFlowFormContainerHeader, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -28,6 +33,8 @@ export type EditDocumentFormProps = { fields: Field[]; }; +type EditDocumentStep = 'signers' | 'fields' | 'subject'; + export const EditDocumentForm = ({ className, document, @@ -38,29 +45,34 @@ export const EditDocumentForm = ({ const { toast } = useToast(); const router = useRouter(); - const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers'); + const [step, setStep] = useState('signers'); const documentUrl = `data:application/pdf;base64,${document.document}`; - const onNextStep = () => { - if (step === 'signers') { - setStep('fields'); - } - - if (step === 'fields') { - setStep('subject'); - } + const documentFlow: Record = { + signers: { + title: 'Add Signers', + description: 'Add the people who will sign the document.', + stepIndex: 1, + onSubmit: () => onAddSignersFormSubmit, + }, + fields: { + title: 'Add Fields', + description: 'Add all relevant fields for each recipient.', + stepIndex: 2, + onBackStep: () => setStep('signers'), + onSubmit: () => onAddFieldsFormSubmit, + }, + subject: { + title: 'Add Subject', + description: 'Add the subject and message you wish to send to signers.', + stepIndex: 3, + onBackStep: () => setStep('fields'), + onSubmit: () => onAddSubjectFormSubmit, + }, }; - const onPreviousStep = () => { - if (step === 'fields') { - setStep('signers'); - } - - if (step === 'subject') { - setStep('fields'); - } - }; + const currentDocumentFlow = documentFlow[step]; const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => { try { @@ -72,7 +84,7 @@ export const EditDocumentForm = ({ router.refresh(); - onNextStep(); + setStep('fields'); } catch (err) { console.error(err); @@ -94,7 +106,7 @@ export const EditDocumentForm = ({ router.refresh(); - onNextStep(); + setStep('subject'); } catch (err) { console.error(err); @@ -119,8 +131,6 @@ export const EditDocumentForm = ({ }); router.refresh(); - - onNextStep(); } catch (err) { console.error(err); @@ -144,38 +154,43 @@ export const EditDocumentForm = ({
- {step === 'signers' && ( - e.preventDefault()}> + - )} - {step === 'fields' && ( - - )} + {step === 'signers' && ( + + )} - {step === 'subject' && ( - - )} + {step === 'fields' && ( + + )} + + {step === 'subject' && ( + + )} +
); diff --git a/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx b/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx index b101385dd..b959b395c 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx @@ -13,9 +13,9 @@ export default function Loading() {

Loading Document...

-
+
-
+

Loading document...

diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index c725d6ec4..574722ad4 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -50,7 +50,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = return (
diff --git a/packages/lib/client-only/hooks/use-document-element.ts b/packages/lib/client-only/hooks/use-document-element.ts new file mode 100644 index 000000000..bd4dc513e --- /dev/null +++ b/packages/lib/client-only/hooks/use-document-element.ts @@ -0,0 +1,93 @@ +'use client'; + +import { useCallback } from 'react'; + +import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; + +export const useDocumentElement = () => { + /** + * Given a mouse event, find the nearest element found by the provided selector. + */ + const getPage = (event: MouseEvent, pageSelector: string) => { + if (!(event.target instanceof HTMLElement)) { + return null; + } + + const target = event.target; + + const $page = + target.closest(pageSelector) ?? target.querySelector(pageSelector); + + 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 element found by the provided selector. + * + * @param mouseWidth The artifical width of the mouse. + * @param mouseHeight The artifical height of the mouse. + */ + const isWithinPageBounds = useCallback( + (event: MouseEvent, pageSelector: string, mouseWidth = 0, mouseHeight = 0) => { + const $page = getPage(event, pageSelector); + + if (!$page) { + return false; + } + + const { top, left, height, width } = $page.getBoundingClientRect(); + + const halfMouseWidth = mouseWidth / 2; + const halfMouseHeight = mouseHeight / 2; + + if (event.clientY > top + height - halfMouseHeight || event.clientY < top + halfMouseHeight) { + return false; + } + + if (event.clientX > left + width - halfMouseWidth || event.clientX < left + halfMouseWidth) { + return false; + } + + return true; + }, + [], + ); + + return { + getPage, + getFieldPosition, + isWithinPageBounds, + }; +}; diff --git a/packages/ui/primitives/button.tsx b/packages/ui/primitives/button.tsx index cf2e48bd2..c67117d6f 100644 --- a/packages/ui/primitives/button.tsx +++ b/packages/ui/primitives/button.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; import { VariantProps, cva } from 'class-variance-authority'; +import { Loader } from 'lucide-react'; import { cn } from '../lib/utils'; @@ -30,17 +31,51 @@ const buttonVariants = cva( }, ); +const loaderVariants = cva('mr-2 animate-spin', { + variants: { + size: { + default: 'h-5 w-5', + sm: 'h-4 w-4', + lg: 'h-5 w-5', + }, + }, + defaultVariants: { + size: 'default', + }, +}); + export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; + + /** + * Will display the loading spinner and disable the button. + */ + loading?: boolean; } const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : 'button'; + if (asChild) { + return ( + + ); + } + + const showLoader = props.loading === true; + const isDisabled = props.disabled || showLoader; + return ( - + ); }, ); diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 890591efd..36595e880 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -9,8 +9,9 @@ import { nanoid } from 'nanoid'; import { useFieldArray, useForm } from 'react-hook-form'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; +import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; -import { Document, Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client'; +import { 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'; @@ -26,14 +27,13 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive import { TAddFieldsFormSchema } from './add-fields.types'; import { - DocumentFlowFormContainer, DocumentFlowFormContainerActions, DocumentFlowFormContainerContent, DocumentFlowFormContainerFooter, DocumentFlowFormContainerStep, } from './document-flow-root'; import { FieldItem } from './field-item'; -import { FRIENDLY_FIELD_TYPE } from './types'; +import { DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types'; const fontCaveat = Caveat({ weight: ['500'], @@ -49,20 +49,24 @@ const MIN_HEIGHT_PX = 60; const MIN_WIDTH_PX = 200; export type AddFieldsFormProps = { + documentFlow: DocumentFlowStep; + hideRecipients?: boolean; recipients: Recipient[]; fields: Field[]; - document: Document; - onContinue?: () => void; - onGoBack?: () => void; + numberOfSteps: number; onSubmit: (_data: TAddFieldsFormSchema) => void; }; export const AddFieldsFormPartial = ({ + documentFlow, + hideRecipients = false, recipients, fields, - onGoBack, + numberOfSteps, onSubmit, }: AddFieldsFormProps) => { + const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement(); + const { control, handleSubmit, @@ -99,7 +103,7 @@ export const AddFieldsFormPartial = ({ const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT; - const [visible, setVisible] = useState(false); + const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false); const [coords, setCoords] = useState({ x: 0, y: 0, @@ -110,86 +114,17 @@ export const AddFieldsFormPartial = ({ width: 0, }); - /** - * Given a mouse event, find the nearest pdf page element. - */ - const getPage = (event: MouseEvent) => { - if (!(event.target instanceof HTMLElement)) { - return null; - } - - const target = event.target; - - const $page = - 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; - } - - const { top, left, height, width } = $page.getBoundingClientRect(); - - if (event.clientY > top + height || event.clientY < top) { - return false; - } - - if (event.clientX > left + width || event.clientX < left) { - return false; - } - - return true; - }, []); - const onMouseMove = useCallback( (event: MouseEvent) => { - if (!isWithinPageBounds(event)) { - setVisible(false); - return; - } + setIsFieldWithinBounds( + isWithinPageBounds( + event, + PDF_VIEWER_PAGE_SELECTOR, + fieldBounds.current.width, + fieldBounds.current.height, + ), + ); - setVisible(true); setCoords({ x: event.clientX - fieldBounds.current.width / 2, y: event.clientY - fieldBounds.current.height / 2, @@ -204,9 +139,18 @@ export const AddFieldsFormPartial = ({ return; } - const $page = getPage(event); + const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR); - if (!$page || !isWithinPageBounds(event)) { + if ( + !$page || + !isWithinPageBounds( + event, + PDF_VIEWER_PAGE_SELECTOR, + fieldBounds.current.width, + fieldBounds.current.height, + ) + ) { + setSelectedField(null); return; } @@ -237,10 +181,10 @@ export const AddFieldsFormPartial = ({ signerEmail: selectedSigner.email, }); - setVisible(false); + setIsFieldWithinBounds(false); setSelectedField(null); }, - [append, isWithinPageBounds, selectedField, selectedSigner], + [append, isWithinPageBounds, selectedField, selectedSigner, getPage], ); const onFieldResize = useCallback( @@ -270,7 +214,7 @@ export const AddFieldsFormPartial = ({ pageHeight, }); }, - [localFields, update], + [getFieldPosition, localFields, update], ); const onFieldMove = useCallback( @@ -293,7 +237,7 @@ export const AddFieldsFormPartial = ({ pageY, }); }, - [localFields, update], + [getFieldPosition, localFields, update], ); useEffect(() => { @@ -328,15 +272,18 @@ export const AddFieldsFormPartial = ({ }, [recipients]); return ( - - + <> +
- {selectedField && visible && ( + {selectedField && ( onFieldResize(options, index)} onMove={(options) => onFieldMove(options, index)} onRemove={() => remove(index)} /> ))} - - - - + + + - - - - + + + + - - {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. - - - )} + + {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.name} ({recipient.email}) + + )} - {!recipient.name && ( - - {recipient.email} - - )} - - ))} - - - - + {!recipient.name && ( + + {recipient.email} + + )} + + ))} + + + + + )} -
-
+
+
diff --git a/packages/ui/primitives/document-flow/field-item.tsx b/packages/ui/primitives/document-flow/field-item.tsx index 5c5d7024a..da6df1182 100644 --- a/packages/ui/primitives/document-flow/field-item.tsx +++ b/packages/ui/primitives/document-flow/field-item.tsx @@ -91,9 +91,10 @@ export const FieldItem = ({ return createPortal( {!disabled && (