import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError } from '@documenso/lib/errors/app-error'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { hasOverlappingFields } from '@documenso/lib/utils/fields-overlap'; import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients'; import { zEmail } from '@documenso/lib/utils/zod'; import { trpc, trpc as trpcReact } from '@documenso/trpc/react'; import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; import { cn } from '@documenso/ui/lib/utils'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@documenso/ui/primitives/dialog'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; import { SpinnerBox } from '@documenso/ui/primitives/spinner'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { Textarea } from '@documenso/ui/primitives/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client'; import { AnimatePresence, motion } from 'framer-motion'; import { AlertTriangleIcon, InfoIcon } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { match } from 'ts-pattern'; import * as z from 'zod'; import { getDistributeErrorMessage } from '~/utils/toast-error-messages'; export type EnvelopeDistributeDialogProps = { onDistribute?: () => Promise; documentRootPath: string; trigger?: React.ReactNode; }; export const ZEnvelopeDistributeFormSchema = z.object({ meta: z.object({ emailId: z.string().nullable(), emailReplyTo: z.preprocess((val) => (val === '' ? undefined : val), zEmail().optional()), subject: z.string(), message: z.string(), distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional().default(DocumentDistributionMethod.EMAIL), }), }); export type TEnvelopeDistributeFormSchema = z.infer; export const EnvelopeDistributeDialog = ({ trigger, documentRootPath, onDistribute, }: EnvelopeDistributeDialogProps) => { const organisation = useCurrentOrganisation(); const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor(); const { toast } = useToast(); const { t, i18n } = useLingui(); const navigate = useNavigate(); const [isOpen, setIsOpen] = useState(false); const [isSyncing, setIsSyncing] = useState(false); const { mutateAsync: distributeEnvelope } = trpcReact.envelope.distribute.useMutation(); const form = useForm({ defaultValues: { meta: { emailId: envelope.documentMeta?.emailId ?? null, emailReplyTo: envelope.documentMeta?.emailReplyTo || undefined, subject: envelope.documentMeta?.subject ?? '', message: envelope.documentMeta?.message ?? '', distributionMethod: envelope.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL, }, }, resolver: zodResolver(ZEnvelopeDistributeFormSchema), }); const { handleSubmit, setValue, watch, formState: { isSubmitting }, } = form; const { data: emailData, isLoading: isLoadingEmails } = trpc.enterprise.organisation.email.find.useQuery( { organisationId: organisation.id, perPage: 100, }, { ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, }, ); const emails = emailData?.data || []; const distributionMethod = watch('meta.distributionMethod'); const recipientsWithIndex = useMemo( () => envelope.recipients.map((recipient, index) => ({ ...recipient, index, })), [envelope.recipients], ); const recipientsMissingSignatureFields = useMemo( () => getRecipientsWithMissingFields(recipientsWithIndex, envelope.fields), [recipientsWithIndex, envelope.fields], ); /** * List of recipients who must have an email due to having auth enabled. */ const recipientsMissingRequiredEmail = useMemo(() => { return recipientsWithIndex.filter((recipient) => { const auth = extractDocumentAuthMethods({ documentAuth: envelope.authOptions, recipientAuth: recipient.authOptions, }); return (auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) && !recipient.email; }); }, [recipientsWithIndex, envelope.authOptions]); /** * Whether any fields significantly overlap each other. This is surfaced as a * non-blocking warning since overlapping fields still allow sending, but can * complicate the signing process or cause fields to behave unexpectedly. */ const hasOverlappingEnvelopeFields = useMemo( () => hasOverlappingFields( envelope.fields.map((field) => ({ id: field.id, envelopeItemId: field.envelopeItemId, page: field.page, positionX: Number(field.positionX), positionY: Number(field.positionY), width: Number(field.width), height: Number(field.height), })), ), [envelope.fields], ); const invalidEnvelopeCode = useMemo(() => { if (recipientsMissingSignatureFields.length > 0) { return 'MISSING_SIGNATURES'; } if (envelope.recipients.length === 0) { return 'MISSING_RECIPIENTS'; } if (recipientsMissingRequiredEmail.length > 0) { return 'MISSING_REQUIRED_EMAIL'; } return null; }, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]); const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => { try { await distributeEnvelope({ envelopeId: envelope.id, meta }); await onDistribute?.(); let redirectPath = `${documentRootPath}/${envelope.id}`; if (meta.distributionMethod === DocumentDistributionMethod.NONE) { redirectPath += '?action=copy-links'; } await navigate(redirectPath); toast({ title: t`Envelope distributed`, description: t`Your envelope has been distributed successfully.`, duration: 5000, }); setIsOpen(false); } catch (err) { const error = AppError.parseError(err); const errorMessage = getDistributeErrorMessage(error.code); toast({ title: i18n._(errorMessage.title), description: i18n._(errorMessage.description), variant: 'destructive', duration: 7500, }); } }; const handleSync = async () => { if (isSyncing) { return; } setIsSyncing(true); try { await syncEnvelope(); } catch (err) { console.error(err); } setIsSyncing(false); }; useEffect(() => { // Default the distribution method tab to the envelope's configured setting. if (isOpen && envelope.documentMeta) { setValue('meta.distributionMethod', envelope.documentMeta.distributionMethod); } // Resync the whole envelope if the envelope is mid saving. if (isOpen && (isAutosaving || autosaveError)) { void handleSync(); } }, [isOpen]); if (envelope.status !== DocumentStatus.DRAFT || envelope.type !== EnvelopeType.DOCUMENT) { return null; } return ( {trigger} Send Document Recipients will be able to sign the document once sent {!invalidEnvelopeCode || isSyncing ? (
{hasOverlappingEnvelopeFields && (
Overlapping fields detected Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected.
)} // eslint-disable-next-line @typescript-eslint/consistent-type-assertions setValue('meta.distributionMethod', value as DocumentDistributionMethod) } value={distributionMethod} className="mb-2" > Email None
{isSyncing ? ( ) : distributionMethod === DocumentDistributionMethod.EMAIL ? (
{organisation.organisationClaim.flags.emailDomains && ( ( Email Sender )} /> )} ( Reply To Email (Optional) )} /> ( Subject (Optional) )} /> ( Message (Optional)