feat: detect fields

This commit is contained in:
Ephraim Atta-Duncan
2025-11-19 00:23:12 +00:00
parent 548a74ab89
commit 92ec5e8ee4
8 changed files with 861 additions and 492 deletions

View File

@ -4,24 +4,35 @@ import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Button } from '@documenso/ui/primitives/button';
import { import {
AlertDialog, Dialog,
AlertDialogAction, DialogContent,
AlertDialogCancel, DialogDescription,
AlertDialogContent, DialogFooter,
AlertDialogDescription, DialogHeader,
AlertDialogFooter, DialogTitle,
AlertDialogHeader, } from '@documenso/ui/primitives/dialog';
AlertDialogTitle,
} from '@documenso/ui/primitives/alert-dialog';
type RecipientDetectionStep = 'PROMPT' | 'PROCESSING'; import type { RecipientForCreation } from '~/utils/detect-document-recipients';
import { SuggestedRecipientsForm } from './suggested-recipients-form';
type RecipientDetectionStep =
| 'PROMPT_DETECT_RECIPIENTS'
| 'DETECTING_RECIPIENTS'
| 'REVIEW_RECIPIENTS'
| 'DETECTING_FIELDS';
export type RecipientDetectionPromptDialogProps = { export type RecipientDetectionPromptDialogProps = {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onAccept: () => Promise<void> | void; onAccept: () => Promise<void> | void;
onSkip: () => void; onSkip: () => void;
recipients: RecipientForCreation[] | null;
onRecipientsSubmit: (recipients: RecipientForCreation[]) => Promise<void> | void;
onAutoAddFields?: (recipients: RecipientForCreation[]) => Promise<void> | void;
isProcessingRecipients?: boolean;
}; };
export const RecipientDetectionPromptDialog = ({ export const RecipientDetectionPromptDialog = ({
@ -29,21 +40,40 @@ export const RecipientDetectionPromptDialog = ({
onOpenChange, onOpenChange,
onAccept, onAccept,
onSkip, onSkip,
recipients,
onRecipientsSubmit,
onAutoAddFields,
isProcessingRecipients = false,
}: RecipientDetectionPromptDialogProps) => { }: RecipientDetectionPromptDialogProps) => {
const [currentStep, setCurrentStep] = useState<RecipientDetectionStep>('PROMPT'); const [currentStep, setCurrentStep] = useState<RecipientDetectionStep>(
'PROMPT_DETECT_RECIPIENTS',
);
const [currentRecipients, setCurrentRecipients] = useState<RecipientForCreation[] | null>(
recipients,
);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setCurrentStep('PROMPT'); setCurrentStep('PROMPT_DETECT_RECIPIENTS');
} }
}, [open]); }, [open]);
useEffect(() => {
setCurrentRecipients(recipients);
}, [recipients]);
useEffect(() => {
if (recipients && currentStep === 'DETECTING_RECIPIENTS') {
setCurrentStep('REVIEW_RECIPIENTS');
}
}, [recipients, currentStep]);
const handleStartDetection = (e: React.MouseEvent) => { const handleStartDetection = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
setCurrentStep('PROCESSING'); setCurrentStep('DETECTING_RECIPIENTS');
Promise.resolve(onAccept()).catch(() => { Promise.resolve(onAccept()).catch(() => {
setCurrentStep('PROMPT'); setCurrentStep('PROMPT_DETECT_RECIPIENTS');
}); });
}; };
@ -51,37 +81,74 @@ export const RecipientDetectionPromptDialog = ({
onSkip(); onSkip();
}; };
const handleAutoAddFields = async (recipients: RecipientForCreation[]) => {
if (!onAutoAddFields) {
return;
}
// Save the current state of recipients so if we fail and come back,
// the form is restored with the user's changes.
setCurrentRecipients(recipients);
setCurrentStep('DETECTING_FIELDS');
try {
await onAutoAddFields(recipients);
} catch {
setCurrentStep('REVIEW_RECIPIENTS');
}
};
return ( return (
<AlertDialog open={open} onOpenChange={onOpenChange}> <Dialog
<AlertDialogContent> open={open}
<fieldset disabled={currentStep === 'PROCESSING'}> onOpenChange={(newOpen) => {
// Prevent closing during processing
if (
!newOpen &&
(currentStep === 'DETECTING_RECIPIENTS' ||
currentStep === 'DETECTING_FIELDS' ||
isProcessingRecipients)
) {
return;
}
onOpenChange(newOpen);
}}
>
<DialogContent
className={
currentStep === 'REVIEW_RECIPIENTS' ? 'max-h-[90vh] max-w-4xl overflow-y-auto' : ''
}
>
<fieldset
disabled={currentStep === 'DETECTING_RECIPIENTS' || currentStep === 'DETECTING_FIELDS'}
>
<AnimateGenericFadeInOut motionKey={currentStep} className="grid gap-4"> <AnimateGenericFadeInOut motionKey={currentStep} className="grid gap-4">
{match(currentStep) {match(currentStep)
.with('PROMPT', () => ( .with('PROMPT_DETECT_RECIPIENTS', () => (
<> <>
<AlertDialogHeader> <DialogHeader>
<AlertDialogTitle> <DialogTitle>
<Trans>Auto-detect recipients?</Trans> <Trans>Auto-detect recipients?</Trans>
</AlertDialogTitle> </DialogTitle>
<AlertDialogDescription> <DialogDescription>
<Trans> <Trans>
Would you like to automatically detect recipients in your document? This can Would you like to automatically detect recipients in your document? This can
save you time in setting up your document. save you time in setting up your document.
</Trans> </Trans>
</AlertDialogDescription> </DialogDescription>
</AlertDialogHeader> </DialogHeader>
<AlertDialogFooter> <DialogFooter>
<AlertDialogCancel onClick={handleSkip}> <Button variant="ghost" onClick={handleSkip}>
<Trans>Skip for now</Trans> <Trans>Skip for now</Trans>
</AlertDialogCancel> </Button>
<AlertDialogAction onClick={handleStartDetection}> <Button onClick={handleStartDetection}>
<Trans>Detect recipients</Trans> <Trans>Detect recipients</Trans>
</AlertDialogAction> </Button>
</AlertDialogFooter> </DialogFooter>
</> </>
)) ))
.with('PROCESSING', () => ( .with('DETECTING_RECIPIENTS', () => (
<div className="flex flex-col items-center justify-center py-4"> <div className="flex flex-col items-center justify-center py-4">
<div className="relative mb-4 flex items-center justify-center"> <div className="relative mb-4 flex items-center justify-center">
<div <div
@ -105,22 +172,80 @@ export const RecipientDetectionPromptDialog = ({
/> />
</div> </div>
<AlertDialogHeader> <DialogHeader>
<AlertDialogTitle className="text-center"> <DialogTitle className="text-center">
<Trans>Analyzing your document</Trans> <Trans>Analyzing your document</Trans>
</AlertDialogTitle> </DialogTitle>
<AlertDialogDescription className="text-center"> <DialogDescription className="text-center">
<Trans> <Trans>
Scanning your document to detect recipient names, emails, and signing order. Scanning your document to detect recipient names, emails, and signing order.
</Trans> </Trans>
</AlertDialogDescription> </DialogDescription>
</AlertDialogHeader> </DialogHeader>
</div> </div>
)) ))
.with('DETECTING_FIELDS', () => (
<div className="flex flex-col items-center justify-center py-4">
<div className="relative mb-4 flex items-center justify-center">
<div
className="border-muted-foreground/20 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left flex-col gap-y-1 overflow-hidden rounded-lg border px-2 py-4 backdrop-blur-sm"
style={{ transform: 'translateZ(0px)' }}
>
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-5/6 rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-4/5 rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-3/4 rounded-[2px]"></div>
</div>
<div
className="bg-documenso/80 animate-scan pointer-events-none absolute left-1/2 top-0 z-20 h-0.5 w-24 -translate-x-1/2"
style={{
transform: 'translateX(-50%) translateZ(0px)',
}}
/>
</div>
<DialogHeader>
<DialogTitle className="text-center">
<Trans>Detecting fields</Trans>
</DialogTitle>
<DialogDescription className="text-center">
<Trans>
Scanning your document to intelligently place fields for your recipients.
</Trans>
</DialogDescription>
</DialogHeader>
</div>
))
.with('REVIEW_RECIPIENTS', () => (
<>
<DialogHeader>
<DialogTitle>
<Trans>Review detected recipients</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Confirm, edit, or add recipients before continuing. You can adjust any
information below before importing it into your document.
</Trans>
</DialogDescription>
</DialogHeader>
<SuggestedRecipientsForm
recipients={currentRecipients}
onCancel={handleSkip}
onSubmit={onRecipientsSubmit}
onAutoAddFields={onAutoAddFields ? handleAutoAddFields : undefined}
isProcessing={isProcessingRecipients}
/>
</>
))
.exhaustive()} .exhaustive()}
</AnimateGenericFadeInOut> </AnimateGenericFadeInOut>
</fieldset> </fieldset>
</AlertDialogContent> </DialogContent>
</AlertDialog> </Dialog>
); );
}; };

View File

@ -1,366 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { PlusIcon, TrashIcon } from 'lucide-react';
import { type FieldError, useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { nanoid } from '@documenso/lib/universal/id';
import { trpc } from '@documenso/trpc/react';
import { RecipientAutoCompleteInput } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import type { RecipientForCreation } from '~/utils/detect-document-recipients';
const ZSuggestedRecipientSchema = z.object({
formId: z.string().min(1),
name: z
.string()
.min(1, { message: msg`Name is required`.id })
.max(255),
email: z
.string()
.min(1, { message: msg`Email is required`.id })
.email({ message: msg`Invalid email`.id })
.max(254),
role: z.nativeEnum(RecipientRole),
});
const ZSuggestedRecipientsFormSchema = z.object({
recipients: z
.array(ZSuggestedRecipientSchema)
.min(1, { message: msg`Please add at least one recipient`.id }),
});
type TSuggestedRecipientsFormSchema = z.infer<typeof ZSuggestedRecipientsFormSchema>;
export type SuggestedRecipientsDialogProps = {
open: boolean;
recipients: RecipientForCreation[] | null;
onOpenChange: (open: boolean) => void;
onCancel: () => void;
onSubmit: (recipients: RecipientForCreation[]) => Promise<void> | void;
};
export const SuggestedRecipientsDialog = ({
open,
recipients,
onOpenChange,
onCancel,
onSubmit,
}: SuggestedRecipientsDialogProps) => {
const { t } = useLingui();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const defaultRecipients = useMemo(() => {
if (recipients && recipients.length > 0) {
const sorted = [...recipients].sort((a, b) => {
const orderA = a.signingOrder ?? 0;
const orderB = b.signingOrder ?? 0;
return orderA - orderB;
});
return sorted.map((recipient) => ({
formId: nanoid(),
name: recipient.name,
email: recipient.email,
role: recipient.role,
}));
}
return [
{
formId: nanoid(),
name: '',
email: '',
role: RecipientRole.SIGNER,
},
];
}, [recipients]);
const form = useForm<TSuggestedRecipientsFormSchema>({
resolver: zodResolver(ZSuggestedRecipientsFormSchema),
defaultValues: {
recipients: defaultRecipients,
},
});
const {
formState: { isSubmitting },
} = form;
useEffect(() => {
form.reset({
recipients: defaultRecipients,
});
}, [defaultRecipients, form]);
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'recipients',
});
const handleRecipientAutoCompleteSelect = (
index: number,
suggestion: RecipientAutoCompleteOption,
) => {
form.setValue(`recipients.${index}.email`, suggestion.email);
form.setValue(`recipients.${index}.name`, suggestion.name ?? suggestion.email);
};
const handleAddSigner = () => {
append({
formId: nanoid(),
name: '',
email: '',
role: RecipientRole.SIGNER,
});
};
const handleRemoveSigner = (index: number) => {
remove(index);
};
const handleSubmit = form.handleSubmit(async (values) => {
const normalizedRecipients: RecipientForCreation[] = values.recipients.map(
(recipient, index) => ({
name: recipient.name.trim(),
email: recipient.email.trim(),
role: recipient.role,
signingOrder: index + 1,
}),
);
try {
await onSubmit(normalizedRecipients);
} catch {
// Form level errors are surfaced via toasts in the parent. Keep the dialog open.
}
});
const getRecipientsRootError = (
error: typeof form.formState.errors.recipients,
): FieldError | undefined => {
if (typeof error !== 'object' || !error || !('root' in error)) {
return undefined;
}
const candidate = (error as { root?: FieldError }).root;
return typeof candidate === 'object' ? candidate : undefined;
};
const recipientsRootError = getRecipientsRootError(form.formState.errors.recipients);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
<Trans>Review detected recipients</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Confirm, edit, or add recipients before continuing. You can adjust any information
below before importing it into your document.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
{fields.map((field, index) => (
<div
key={field.id}
className="flex flex-col gap-4 md:flex-row md:items-center md:gap-x-2"
>
<FormField
control={form.control}
name={`recipients.${index}.email`}
render={({ field: emailField }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.recipients?.[index] &&
!form.formState.errors.recipients[index]?.email,
})}
>
{index === 0 && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={emailField.value}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
emailField.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
disabled={isSubmitting}
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`recipients.${index}.name`}
render={({ field: nameField }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.recipients?.[index] &&
!form.formState.errors.recipients[index]?.name,
})}
>
{index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Name`}
value={nameField.value}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
nameField.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
disabled={isSubmitting}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`recipients.${index}.role`}
render={({ field: roleField }) => (
<FormItem
className={cn('mt-2 w-full md:mt-auto md:w-fit', {
'mb-6':
form.formState.errors.recipients?.[index] &&
!form.formState.errors.recipients[index]?.role,
})}
>
{index === 0 && (
<FormLabel>
<Trans>Role</Trans>
</FormLabel>
)}
<FormControl>
<RecipientRoleSelect
value={roleField.value}
onValueChange={roleField.onChange}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
className={cn('mt-2 w-full px-2 md:mt-auto md:w-auto', {
'mb-6': form.formState.errors.recipients?.[index],
})}
onClick={() => handleRemoveSigner(index)}
disabled={isSubmitting || fields.length === 1}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<FormErrorMessage className="mt-2" error={recipientsRootError} />
<div className="flex">
<Button
type="button"
variant="outline"
onClick={handleAddSigner}
className="w-full md:w-auto"
disabled={isSubmitting}
>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add signer</Trans>
</Button>
</div>
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end sm:gap-3">
<div className="flex flex-col gap-2 sm:flex-row sm:gap-3">
<Button type="button" variant="ghost" onClick={onCancel} disabled={isSubmitting}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Use recipients</Trans>
</Button>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,412 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { PlusIcon, TrashIcon } from 'lucide-react';
import { type FieldError, useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { nanoid } from '@documenso/lib/universal/id';
import { trpc } from '@documenso/trpc/react';
import { RecipientAutoCompleteInput } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import type { RecipientForCreation } from '~/utils/detect-document-recipients';
const ZSuggestedRecipientSchema = z.object({
formId: z.string().min(1),
name: z
.string()
.min(1, { message: msg`Name is required`.id })
.max(255),
email: z
.string()
.min(1, { message: msg`Email is required`.id })
.email({ message: msg`Invalid email`.id })
.max(254),
role: z.nativeEnum(RecipientRole),
});
const ZSuggestedRecipientsFormSchema = z.object({
recipients: z
.array(ZSuggestedRecipientSchema)
.min(1, { message: msg`Please add at least one recipient`.id }),
});
type TSuggestedRecipientsFormSchema = z.infer<typeof ZSuggestedRecipientsFormSchema>;
export type SuggestedRecipientsFormProps = {
recipients: RecipientForCreation[] | null;
onCancel: () => void;
onSubmit: (recipients: RecipientForCreation[]) => Promise<void> | void;
onAutoAddFields?: (recipients: RecipientForCreation[]) => Promise<void> | void;
isProcessing?: boolean;
};
export const SuggestedRecipientsForm = ({
recipients,
onCancel,
onSubmit,
onAutoAddFields,
isProcessing = false,
}: SuggestedRecipientsFormProps) => {
const { t } = useLingui();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const defaultRecipients = useMemo(() => {
if (recipients && recipients.length > 0) {
const sorted = [...recipients].sort((a, b) => {
const orderA = a.signingOrder ?? 0;
const orderB = b.signingOrder ?? 0;
return orderA - orderB;
});
return sorted.map((recipient) => ({
formId: nanoid(),
name: recipient.name,
email: recipient.email,
role: recipient.role,
}));
}
return [
{
formId: nanoid(),
name: '',
email: '',
role: RecipientRole.SIGNER,
},
];
}, [recipients]);
const form = useForm<TSuggestedRecipientsFormSchema>({
resolver: zodResolver(ZSuggestedRecipientsFormSchema),
defaultValues: {
recipients: defaultRecipients,
},
});
const {
formState: { isSubmitting },
} = form;
useEffect(() => {
form.reset({
recipients: defaultRecipients,
});
}, [defaultRecipients, form]);
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'recipients',
});
const handleRecipientAutoCompleteSelect = (
index: number,
suggestion: RecipientAutoCompleteOption,
) => {
form.setValue(`recipients.${index}.email`, suggestion.email);
form.setValue(`recipients.${index}.name`, suggestion.name ?? suggestion.email);
};
const handleAddSigner = () => {
append({
formId: nanoid(),
name: '',
email: '',
role: RecipientRole.SIGNER,
});
};
const handleRemoveSigner = (index: number) => {
remove(index);
};
const handleSubmit = form.handleSubmit(async (values) => {
const normalizedRecipients: RecipientForCreation[] = values.recipients.map(
(recipient, index) => ({
name: recipient.name.trim(),
email: recipient.email.trim(),
role: recipient.role,
signingOrder: index + 1,
}),
);
try {
await onSubmit(normalizedRecipients);
} catch (error) {
// Log for debugging
console.error('Failed to submit recipients:', error);
// Form level errors are surfaced via toasts in the parent. Keep the dialog open.
}
});
const handleAutoAddFields = form.handleSubmit(async (values) => {
if (!onAutoAddFields) {
return;
}
const normalizedRecipients: RecipientForCreation[] = values.recipients.map(
(recipient, index) => ({
name: recipient.name.trim(),
email: recipient.email.trim(),
role: recipient.role,
signingOrder: index + 1,
}),
);
try {
await onAutoAddFields(normalizedRecipients);
} catch (error) {
// Log for debugging
console.error('Failed to auto-add fields:', error);
// Form level errors are surfaced via toasts in the parent. Keep the dialog open.
}
});
const getRecipientsRootError = (
error: typeof form.formState.errors.recipients,
): FieldError | undefined => {
if (typeof error !== 'object' || !error || !('root' in error)) {
return undefined;
}
const candidate = (error as { root?: FieldError }).root;
return typeof candidate === 'object' ? candidate : undefined;
};
const recipientsRootError = getRecipientsRootError(form.formState.errors.recipients);
return (
<Form {...form}>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
{fields.map((field, index) => (
<div
key={field.id}
className="flex flex-col gap-4 md:flex-row md:items-center md:gap-x-2"
>
<FormField
control={form.control}
name={`recipients.${index}.email`}
render={({ field: emailField }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.recipients?.[index] &&
!form.formState.errors.recipients[index]?.email,
})}
>
{index === 0 && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={emailField.value}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
emailField.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
disabled={isSubmitting}
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`recipients.${index}.name`}
render={({ field: nameField }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.recipients?.[index] &&
!form.formState.errors.recipients[index]?.name,
})}
>
{index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Name`}
value={nameField.value}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
nameField.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
disabled={isSubmitting}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`recipients.${index}.role`}
render={({ field: roleField }) => (
<FormItem
className={cn('mt-2 w-full md:mt-auto md:w-fit', {
'mb-6':
form.formState.errors.recipients?.[index] &&
!form.formState.errors.recipients[index]?.role,
})}
>
{index === 0 && (
<FormLabel>
<Trans>Role</Trans>
</FormLabel>
)}
<FormControl>
<RecipientRoleSelect
value={roleField.value}
onValueChange={roleField.onChange}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
className={cn('mt-2 w-full px-2 md:mt-auto md:w-auto', {
'mb-6': form.formState.errors.recipients?.[index],
})}
onClick={() => handleRemoveSigner(index)}
disabled={isSubmitting || fields.length === 1}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<FormErrorMessage className="mt-2" error={recipientsRootError} />
<div className="flex">
<Button
type="button"
variant="outline"
onClick={handleAddSigner}
className="w-full md:w-auto"
disabled={isSubmitting}
>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add signer</Trans>
</Button>
</div>
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-between sm:gap-3">
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={isSubmitting || isProcessing}
>
<Trans>Cancel</Trans>
</Button>
<div className="flex flex-col gap-2 sm:flex-row sm:gap-3">
<Button type="submit" disabled={isSubmitting || isProcessing}>
<Trans>Use recipients</Trans>
</Button>
{onAutoAddFields && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
type="button"
onClick={handleAutoAddFields}
disabled={
isSubmitting ||
isProcessing ||
fields.length === 0 ||
!form.formState.isValid
}
>
{isProcessing ? (
<Trans>Processing...</Trans>
) : (
<Trans>Auto add fields</Trans>
)}
</Button>
</div>
</TooltipTrigger>
{(fields.length === 0 || !form.formState.isValid) && (
<TooltipContent>
<Trans>Please add at least one valid recipient to auto-detect fields</Trans>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
</div>
</DialogFooter>
</form>
</Form>
);
};

View File

@ -1,7 +1,7 @@
import { lazy, useEffect, useMemo, useState } from 'react'; import { lazy, useEffect, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core'; import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro'; import { msg, plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon } from 'lucide-react'; import { FileTextIcon } from 'lucide-react';
@ -54,7 +54,6 @@ const EnvelopeEditorFieldsPageRenderer = lazy(
async () => import('./envelope-editor-fields-page-renderer'), async () => import('./envelope-editor-fields-page-renderer'),
); );
// Expands fields to minimum usable dimensions (30px height, 36px width) and centers them
const enforceMinimumFieldDimensions = (params: { const enforceMinimumFieldDimensions = (params: {
positionX: number; positionX: number;
positionY: number; positionY: number;
@ -184,6 +183,7 @@ export const EnvelopeEditorFieldsPage = () => {
current: number; current: number;
total: number; total: number;
} | null>(null); } | null>(null);
const [hasAutoPlacedFields, setHasAutoPlacedFields] = useState(false);
const selectedField = useMemo( const selectedField = useMemo(
() => structuredClone(editorFields.selectedField), () => structuredClone(editorFields.selectedField),
@ -204,9 +204,6 @@ export const EnvelopeEditorFieldsPage = () => {
} }
}; };
/**
* Set the selected recipient to the first recipient in the envelope.
*/
useEffect(() => { useEffect(() => {
const firstSelectableRecipient = envelope.recipients.find( const firstSelectableRecipient = envelope.recipients.find(
(recipient) => (recipient) =>
@ -216,16 +213,143 @@ export const EnvelopeEditorFieldsPage = () => {
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null); editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
}, []); }, []);
useEffect(() => {
if (hasAutoPlacedFields || !currentEnvelopeItem) {
return;
}
const storageKey = `autoPlaceFields_${envelope.id}`;
const storedData = sessionStorage.getItem(storageKey);
if (!storedData) {
return;
}
sessionStorage.removeItem(storageKey);
setHasAutoPlacedFields(true);
try {
const { fields: detectedFields, recipientCount } = JSON.parse(storedData) as {
fields: TDetectedFormField[];
recipientCount: number;
};
let totalAdded = 0;
const fieldsPerPage = new Map<number, TDetectedFormField[]>();
for (const field of detectedFields) {
if (!fieldsPerPage.has(field.pageNumber)) {
fieldsPerPage.set(field.pageNumber, []);
}
fieldsPerPage.get(field.pageNumber)!.push(field);
}
for (const [pageNumber, fields] of fieldsPerPage.entries()) {
const pageCanvasRefs = getPageCanvasRefs(pageNumber);
for (const detected of fields) {
const [ymin, xmin, ymax, xmax] = detected.boundingBox;
let positionX = (xmin / 1000) * 100;
let positionY = (ymin / 1000) * 100;
let width = ((xmax - xmin) / 1000) * 100;
let height = ((ymax - ymin) / 1000) * 100;
if (pageCanvasRefs) {
const adjusted = enforceMinimumFieldDimensions({
positionX,
positionY,
width,
height,
pageWidth: pageCanvasRefs.pdfCanvas.width,
pageHeight: pageCanvasRefs.pdfCanvas.height,
});
positionX = adjusted.positionX;
positionY = adjusted.positionY;
width = adjusted.width;
height = adjusted.height;
}
const fieldType = detected.label as FieldType;
const resolvedRecipientId =
envelope.recipients.find((recipient) => recipient.id === detected.recipientId)?.id ??
editorFields.selectedRecipient?.id ??
envelope.recipients[0]?.id;
if (!resolvedRecipientId) {
console.warn('Skipping detected field because no recipient could be resolved', {
detectedRecipientId: detected.recipientId,
});
continue;
}
try {
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
page: pageNumber,
type: fieldType,
positionX,
positionY,
width,
height,
recipientId: resolvedRecipientId,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
});
totalAdded++;
} catch (error) {
console.error(`Failed to add field on page ${pageNumber}:`, error);
}
}
}
if (totalAdded > 0) {
toast({
title: t`Recipients and fields added`,
description: t`Added ${recipientCount} ${plural(recipientCount, {
one: 'recipient',
other: 'recipients',
})} and ${totalAdded} ${plural(totalAdded, { one: 'field', other: 'fields' })}`,
duration: 5000,
});
} else {
toast({
title: t`Recipients added`,
description: t`Added ${recipientCount} ${plural(recipientCount, {
one: 'recipient',
other: 'recipients',
})}. No fields were detected in the document.`,
duration: 5000,
});
}
} catch (error) {
console.error('Failed to auto-place fields:', error);
toast({
title: t`Field placement failed`,
description: t`Failed to automatically place fields. You can add them manually.`,
variant: 'destructive',
duration: 5000,
});
}
}, [
currentEnvelopeItem,
envelope.id,
envelope.recipients,
editorFields,
hasAutoPlacedFields,
t,
toast,
]);
return ( return (
<div className="relative flex h-full"> <div className="relative flex h-full">
<div className="relative flex w-full flex-col overflow-y-auto"> <div className="relative flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */} {/* Horizontal envelope item selector */}
{isDetectingFields && ( {isDetectingFields && (
<> <>
<div className="edge-glow edge-glow-top pointer-events-none fixed left-0 right-0 top-0 z-20 h-16" /> <div className="edge-glow edge-glow-top pointer-events-none fixed left-0 right-0 top-0 z-20 h-32" />
<div className="edge-glow edge-glow-right pointer-events-none fixed bottom-0 right-0 top-0 z-20 w-16" /> <div className="edge-glow edge-glow-right pointer-events-none fixed bottom-0 right-0 top-0 z-20 w-32" />
<div className="edge-glow edge-glow-bottom pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-16" /> <div className="edge-glow edge-glow-bottom pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-32" />
<div className="edge-glow edge-glow-left pointer-events-none fixed bottom-0 left-0 top-0 z-20 w-16" /> <div className="edge-glow edge-glow-left pointer-events-none fixed bottom-0 left-0 top-0 z-20 w-32" />
</> </>
)} )}
@ -330,7 +454,7 @@ export const EnvelopeEditorFieldsPage = () => {
try { try {
if (!currentEnvelopeItem) { if (!currentEnvelopeItem) {
toast({ toast({
title: t`Error`, title: t`No document selected`,
description: t`No document selected. Please reload the page and try again.`, description: t`No document selected. Please reload the page and try again.`,
variant: 'destructive', variant: 'destructive',
}); });
@ -339,7 +463,7 @@ export const EnvelopeEditorFieldsPage = () => {
if (!currentEnvelopeItem.documentDataId) { if (!currentEnvelopeItem.documentDataId) {
toast({ toast({
title: t`Error`, title: t`Document data missing`,
description: t`Document data not found. Please try reloading the page.`, description: t`Document data not found. Please try reloading the page.`,
variant: 'destructive', variant: 'destructive',
}); });
@ -430,24 +554,24 @@ export const EnvelopeEditorFieldsPage = () => {
} }
toast({ toast({
title: t`Success`, title: t`Fields added`,
description, description,
}); });
} else if (failedPages > 0) { } else if (failedPages > 0) {
toast({ toast({
title: t`Error`, title: t`Field detection failed`,
description: t`Failed to detect fields on ${failedPages} pages. Please try again.`, description: t`Failed to detect fields on ${failedPages} pages. Please try again.`,
variant: 'destructive', variant: 'destructive',
}); });
} else { } else {
toast({ toast({
title: t`Info`, title: t`No fields detected`,
description: t`No fields were detected in the document`, description: t`No fields were detected in the document`,
}); });
} }
} catch (error) { } catch (error) {
toast({ toast({
title: t`Error`, title: t`Processing error`,
description: t`An unexpected error occurred while processing pages.`, description: t`An unexpected error occurred while processing pages.`,
variant: 'destructive', variant: 'destructive',
}); });

View File

@ -1,5 +1,6 @@
import { type ReactNode, useState } from 'react'; import { type ReactNode, useState } from 'react';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client'; import { EnvelopeType } from '@prisma/client';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
@ -27,7 +28,6 @@ import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog'; import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
import { SuggestedRecipientsDialog } from '~/components/dialogs/suggested-recipients-dialog';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { import {
type RecipientForCreation, type RecipientForCreation,
@ -61,7 +61,6 @@ export const EnvelopeDropZoneWrapper = ({
const [showExtractionPrompt, setShowExtractionPrompt] = useState(false); const [showExtractionPrompt, setShowExtractionPrompt] = useState(false);
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null); const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null); const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
const [showRecipientsDialog, setShowRecipientsDialog] = useState(false);
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true); const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
const userTimezone = const userTimezone =
@ -124,7 +123,6 @@ export const EnvelopeDropZoneWrapper = ({
// Show AI prompt dialog for documents // Show AI prompt dialog for documents
setUploadedDocumentId(id); setUploadedDocumentId(id);
setPendingRecipients(null); setPendingRecipients(null);
setShowRecipientsDialog(false);
setShouldNavigateAfterPromptClose(true); setShouldNavigateAfterPromptClose(true);
setShowExtractionPrompt(true); setShowExtractionPrompt(true);
} else { } else {
@ -148,7 +146,7 @@ export const EnvelopeDropZoneWrapper = ({
.otherwise(() => t`An error occurred during upload.`); .otherwise(() => t`An error occurred during upload.`);
toast({ toast({
title: t`Error`, title: t`Upload failed`,
description: errorMessage, description: errorMessage,
variant: 'destructive', variant: 'destructive',
duration: 7500, duration: 7500,
@ -252,8 +250,6 @@ export const EnvelopeDropZoneWrapper = ({
setPendingRecipients(recipientsWithEmails); setPendingRecipients(recipientsWithEmails);
setShouldNavigateAfterPromptClose(false); setShouldNavigateAfterPromptClose(false);
setShowExtractionPrompt(false);
setShowRecipientsDialog(true);
} catch (error) { } catch (error) {
if (!(error instanceof Error && error.message === 'NO_RECIPIENTS_DETECTED')) { if (!(error instanceof Error && error.message === 'NO_RECIPIENTS_DETECTED')) {
const parsedError = AppError.parseError(error); const parsedError = AppError.parseError(error);
@ -276,12 +272,6 @@ export const EnvelopeDropZoneWrapper = ({
navigateToEnvelopeEditor(); navigateToEnvelopeEditor();
}; };
const handleRecipientsCancel = () => {
setShowRecipientsDialog(false);
setPendingRecipients(null);
navigateToEnvelopeEditor();
};
const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => { const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => {
if (!uploadedDocumentId) { if (!uploadedDocumentId) {
return; return;
@ -295,11 +285,17 @@ export const EnvelopeDropZoneWrapper = ({
toast({ toast({
title: t`Recipients added`, title: t`Recipients added`,
description: t`Successfully detected ${recipientsToCreate.length} recipient(s)`, description: t`Successfully detected ${recipientsToCreate.length} ${plural(
recipientsToCreate.length,
{
one: 'recipient',
other: 'recipients',
},
)}`,
duration: 5000, duration: 5000,
}); });
setShowRecipientsDialog(false); setShowExtractionPrompt(false);
setPendingRecipients(null); setPendingRecipients(null);
navigateToEnvelopeEditor(); navigateToEnvelopeEditor();
} catch (error) { } catch (error) {
@ -401,20 +397,8 @@ export const EnvelopeDropZoneWrapper = ({
onOpenChange={handlePromptDialogOpenChange} onOpenChange={handlePromptDialogOpenChange}
onAccept={handleStartRecipientDetection} onAccept={handleStartRecipientDetection}
onSkip={handleSkipRecipientDetection} onSkip={handleSkipRecipientDetection}
/>
<SuggestedRecipientsDialog
open={showRecipientsDialog}
recipients={pendingRecipients} recipients={pendingRecipients}
onOpenChange={(open) => { onRecipientsSubmit={handleRecipientsConfirm}
if (!open) {
handleRecipientsCancel();
} else {
setShowRecipientsDialog(true);
}
}}
onCancel={handleRecipientsCancel}
onSubmit={handleRecipientsConfirm}
/> />
</div> </div>
); );

View File

@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg, plural } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client'; import { EnvelopeType } from '@prisma/client';
@ -28,8 +28,8 @@ import {
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog'; import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
import { SuggestedRecipientsDialog } from '~/components/dialogs/suggested-recipients-dialog';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { detectFieldsInDocument } from '~/utils/detect-document-fields';
import { import {
type RecipientForCreation, type RecipientForCreation,
detectRecipientsInDocument, detectRecipientsInDocument,
@ -65,8 +65,8 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
const [showExtractionPrompt, setShowExtractionPrompt] = useState(false); const [showExtractionPrompt, setShowExtractionPrompt] = useState(false);
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null); const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null); const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
const [showRecipientsDialog, setShowRecipientsDialog] = useState(false);
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true); const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
const [isAutoAddingFields, setIsAutoAddingFields] = useState(false);
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation(); const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
const { mutateAsync: createRecipients } = trpc.envelope.recipient.createMany.useMutation(); const { mutateAsync: createRecipients } = trpc.envelope.recipient.createMany.useMutation();
@ -121,11 +121,9 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
? formatDocumentsPath(team.url) ? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url); : formatTemplatesPath(team.url);
// Show AI prompt dialog for documents only
if (type === EnvelopeType.DOCUMENT) { if (type === EnvelopeType.DOCUMENT) {
setUploadedDocumentId(id); setUploadedDocumentId(id);
setPendingRecipients(null); setPendingRecipients(null);
setShowRecipientsDialog(false);
setShouldNavigateAfterPromptClose(true); setShouldNavigateAfterPromptClose(true);
setShowExtractionPrompt(true); setShowExtractionPrompt(true);
@ -135,7 +133,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
duration: 5000, duration: 5000,
}); });
} else { } else {
// Templates - navigate immediately
await navigate(`${pathPrefix}/${id}/edit`); await navigate(`${pathPrefix}/${id}/edit`);
toast({ toast({
@ -162,7 +159,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
.otherwise(() => t`An error occurred while uploading your document.`); .otherwise(() => t`An error occurred while uploading your document.`);
toast({ toast({
title: t`Error`, title: t`Upload failed`,
description: errorMessage, description: errorMessage,
variant: 'destructive', variant: 'destructive',
duration: 7500, duration: 7500,
@ -229,12 +226,11 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
setPendingRecipients(recipientsWithEmails); setPendingRecipients(recipientsWithEmails);
setShouldNavigateAfterPromptClose(false); setShouldNavigateAfterPromptClose(false);
setShowExtractionPrompt(false);
setShowRecipientsDialog(true);
} catch (err) { } catch (err) {
if (!(err instanceof Error && err.message === 'NO_RECIPIENTS_DETECTED')) { const error = AppError.parseError(err);
const error = AppError.parseError(err);
// Only show toast if this wasn't a "no recipients found" case
if (error.code !== 'NO_RECIPIENTS_DETECTED') {
toast({ toast({
title: t`Failed to analyze recipients`, title: t`Failed to analyze recipients`,
description: error.userMessage || t`You can add recipients manually in the editor`, description: error.userMessage || t`You can add recipients manually in the editor`,
@ -243,7 +239,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
}); });
} }
throw err; throw error;
} }
}; };
@ -253,12 +249,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
navigateToEnvelopeEditor(); navigateToEnvelopeEditor();
}; };
const handleRecipientsCancel = () => {
setShowRecipientsDialog(false);
setPendingRecipients(null);
navigateToEnvelopeEditor();
};
const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => { const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => {
if (!uploadedDocumentId) { if (!uploadedDocumentId) {
return; return;
@ -272,11 +262,17 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
toast({ toast({
title: t`Recipients added`, title: t`Recipients added`,
description: t`Successfully detected ${recipientsToCreate.length} recipient(s)`, description: t`Successfully detected ${recipientsToCreate.length} ${plural(
recipientsToCreate.length,
{
one: 'recipient',
other: 'recipients',
},
)}`,
duration: 5000, duration: 5000,
}); });
setShowRecipientsDialog(false); setShowExtractionPrompt(false);
setPendingRecipients(null); setPendingRecipients(null);
navigateToEnvelopeEditor(); navigateToEnvelopeEditor();
} catch (err) { } catch (err) {
@ -289,7 +285,72 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
duration: 7500, duration: 7500,
}); });
throw err; // Error is handled, dialog stays open for retry
}
};
const handleAutoAddFields = async (recipientsToCreate: RecipientForCreation[]) => {
if (!uploadedDocumentId) {
return;
}
setIsAutoAddingFields(true);
try {
await createRecipients({
envelopeId: uploadedDocumentId,
data: recipientsToCreate,
});
let detectedFields;
try {
detectedFields = await detectFieldsInDocument(uploadedDocumentId);
} catch (error) {
console.error('Field detection failed:', error);
toast({
title: t`Field detection failed`,
description: t`Recipients added successfully, but field detection encountered an error. You can add fields manually.`,
variant: 'destructive',
duration: 7500,
});
setShowExtractionPrompt(false);
setPendingRecipients(null);
setIsAutoAddingFields(false);
const pathPrefix = formatDocumentsPath(team.url);
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit?step=addFields`);
return;
}
if (detectedFields.length > 0) {
sessionStorage.setItem(
`autoPlaceFields_${uploadedDocumentId}`,
JSON.stringify({
fields: detectedFields,
recipientCount: recipientsToCreate.length,
}),
);
}
setShowExtractionPrompt(false);
setPendingRecipients(null);
setIsAutoAddingFields(false);
const pathPrefix = formatDocumentsPath(team.url);
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit?step=addFields`);
} catch (err) {
const error = AppError.parseError(err);
toast({
title: t`Failed to add recipients`,
description: error.userMessage || t`Please try again`,
variant: 'destructive',
duration: 7500,
});
setIsAutoAddingFields(false);
} }
}; };
@ -344,20 +405,10 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
onOpenChange={handlePromptDialogOpenChange} onOpenChange={handlePromptDialogOpenChange}
onAccept={handleStartRecipientDetection} onAccept={handleStartRecipientDetection}
onSkip={handleSkipRecipientDetection} onSkip={handleSkipRecipientDetection}
/>
<SuggestedRecipientsDialog
open={showRecipientsDialog}
recipients={pendingRecipients} recipients={pendingRecipients}
onOpenChange={(open) => { onRecipientsSubmit={handleRecipientsConfirm}
if (!open) { onAutoAddFields={handleAutoAddFields}
handleRecipientsCancel(); isProcessingRecipients={isAutoAddingFields}
} else {
setShowRecipientsDialog(true);
}
}}
onCancel={handleRecipientsCancel}
onSubmit={handleRecipientsConfirm}
/> />
</div> </div>
); );

View File

@ -0,0 +1,39 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TDetectedFormField } from '@documenso/lib/types/document-analysis';
export const detectFieldsInDocument = async (envelopeId: string): Promise<TDetectedFormField[]> => {
const response = await fetch('/api/ai/detect-fields', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId }),
credentials: 'include',
});
if (!response.ok) {
const errorText = await response.text();
console.error('Field detection failed:', {
status: response.status,
statusText: response.statusText,
error: errorText,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Field detection failed: ${response.statusText}`,
userMessage: 'Failed to detect fields in the document. Please try adding fields manually.',
});
}
const data = await response.json();
if (!Array.isArray(data)) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Invalid response from field detection API - expected array',
userMessage: 'Failed to detect fields in the document. Please try again.',
});
}
return data;
};

View File

@ -200,22 +200,22 @@
@keyframes edgeGlow { @keyframes edgeGlow {
0%, 0%,
100% { 100% {
opacity: 0.3; opacity: 0.6;
} }
50% { 50% {
opacity: 0.6; opacity: 1;
} }
} }
.edge-glow { .edge-glow {
animation: edgeGlow 2s ease-in-out infinite; animation: edgeGlow 1.25s ease-in-out infinite;
} }
.edge-glow-top { .edge-glow-top {
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
rgba(162, 231, 113, 0.4) 0%, rgba(162, 231, 113, 0.8) 0%,
rgba(162, 231, 113, 0.2) 20%, rgba(162, 231, 113, 0.4) 20%,
transparent 100% transparent 100%
); );
} }
@ -223,8 +223,8 @@
.edge-glow-right { .edge-glow-right {
background: linear-gradient( background: linear-gradient(
to left, to left,
rgba(162, 231, 113, 0.4) 0%, rgba(162, 231, 113, 0.8) 0%,
rgba(162, 231, 113, 0.2) 20%, rgba(162, 231, 113, 0.4) 20%,
transparent 100% transparent 100%
); );
} }
@ -232,8 +232,8 @@
.edge-glow-bottom { .edge-glow-bottom {
background: linear-gradient( background: linear-gradient(
to top, to top,
rgba(162, 231, 113, 0.4) 0%, rgba(162, 231, 113, 0.8) 0%,
rgba(162, 231, 113, 0.2) 20%, rgba(162, 231, 113, 0.4) 20%,
transparent 100% transparent 100%
); );
} }
@ -241,8 +241,8 @@
.edge-glow-left { .edge-glow-left {
background: linear-gradient( background: linear-gradient(
to right, to right,
rgba(162, 231, 113, 0.4) 0%, rgba(162, 231, 113, 0.8) 0%,
rgba(162, 231, 113, 0.2) 20%, rgba(162, 231, 113, 0.4) 20%,
transparent 100% transparent 100%
); );
} }