From 92ec5e8ee4178fcb6c8c0660e31d51dd846a699a Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Wed, 19 Nov 2025 00:23:12 +0000 Subject: [PATCH] feat: detect fields --- .../recipient-detection-prompt-dialog.tsx | 203 +++++++-- .../dialogs/suggested-recipients-dialog.tsx | 366 ---------------- .../dialogs/suggested-recipients-form.tsx | 412 ++++++++++++++++++ .../envelope-editor-fields-page.tsx | 154 ++++++- .../envelope/envelope-drop-zone-wrapper.tsx | 38 +- .../envelope/envelope-upload-button.tsx | 119 +++-- .../remix/app/utils/detect-document-fields.ts | 39 ++ packages/ui/styles/theme.css | 22 +- 8 files changed, 861 insertions(+), 492 deletions(-) delete mode 100644 apps/remix/app/components/dialogs/suggested-recipients-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/suggested-recipients-form.tsx create mode 100644 apps/remix/app/utils/detect-document-fields.ts diff --git a/apps/remix/app/components/dialogs/recipient-detection-prompt-dialog.tsx b/apps/remix/app/components/dialogs/recipient-detection-prompt-dialog.tsx index c972f4cde..ad77868e1 100644 --- a/apps/remix/app/components/dialogs/recipient-detection-prompt-dialog.tsx +++ b/apps/remix/app/components/dialogs/recipient-detection-prompt-dialog.tsx @@ -4,24 +4,35 @@ import { Trans } from '@lingui/react/macro'; import { match } from 'ts-pattern'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; +import { Button } from '@documenso/ui/primitives/button'; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@documenso/ui/primitives/alert-dialog'; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/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 = { open: boolean; onOpenChange: (open: boolean) => void; onAccept: () => Promise | void; onSkip: () => void; + recipients: RecipientForCreation[] | null; + onRecipientsSubmit: (recipients: RecipientForCreation[]) => Promise | void; + onAutoAddFields?: (recipients: RecipientForCreation[]) => Promise | void; + isProcessingRecipients?: boolean; }; export const RecipientDetectionPromptDialog = ({ @@ -29,21 +40,40 @@ export const RecipientDetectionPromptDialog = ({ onOpenChange, onAccept, onSkip, + recipients, + onRecipientsSubmit, + onAutoAddFields, + isProcessingRecipients = false, }: RecipientDetectionPromptDialogProps) => { - const [currentStep, setCurrentStep] = useState('PROMPT'); + const [currentStep, setCurrentStep] = useState( + 'PROMPT_DETECT_RECIPIENTS', + ); + const [currentRecipients, setCurrentRecipients] = useState( + recipients, + ); useEffect(() => { if (!open) { - setCurrentStep('PROMPT'); + setCurrentStep('PROMPT_DETECT_RECIPIENTS'); } }, [open]); + useEffect(() => { + setCurrentRecipients(recipients); + }, [recipients]); + + useEffect(() => { + if (recipients && currentStep === 'DETECTING_RECIPIENTS') { + setCurrentStep('REVIEW_RECIPIENTS'); + } + }, [recipients, currentStep]); + const handleStartDetection = (e: React.MouseEvent) => { e.preventDefault(); - setCurrentStep('PROCESSING'); + setCurrentStep('DETECTING_RECIPIENTS'); Promise.resolve(onAccept()).catch(() => { - setCurrentStep('PROMPT'); + setCurrentStep('PROMPT_DETECT_RECIPIENTS'); }); }; @@ -51,37 +81,74 @@ export const RecipientDetectionPromptDialog = ({ 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 ( - - -
+ { + // Prevent closing during processing + if ( + !newOpen && + (currentStep === 'DETECTING_RECIPIENTS' || + currentStep === 'DETECTING_FIELDS' || + isProcessingRecipients) + ) { + return; + } + onOpenChange(newOpen); + }} + > + +
{match(currentStep) - .with('PROMPT', () => ( + .with('PROMPT_DETECT_RECIPIENTS', () => ( <> - - + + Auto-detect recipients? - - + + Would you like to automatically detect recipients in your document? This can save you time in setting up your document. - - + + - - + + + + )) - .with('PROCESSING', () => ( + .with('DETECTING_RECIPIENTS', () => (
- - + + Analyzing your document - - + + Scanning your document to detect recipient names, emails, and signing order. - - + +
)) + .with('DETECTING_FIELDS', () => ( +
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + + + Detecting fields + + + + Scanning your document to intelligently place fields for your recipients. + + + +
+ )) + .with('REVIEW_RECIPIENTS', () => ( + <> + + + Review detected recipients + + + + Confirm, edit, or add recipients before continuing. You can adjust any + information below before importing it into your document. + + + + + + )) .exhaustive()}
- - +
+
); }; diff --git a/apps/remix/app/components/dialogs/suggested-recipients-dialog.tsx b/apps/remix/app/components/dialogs/suggested-recipients-dialog.tsx deleted file mode 100644 index e5bcf5c47..000000000 --- a/apps/remix/app/components/dialogs/suggested-recipients-dialog.tsx +++ /dev/null @@ -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; - -export type SuggestedRecipientsDialogProps = { - open: boolean; - recipients: RecipientForCreation[] | null; - onOpenChange: (open: boolean) => void; - onCancel: () => void; - onSubmit: (recipients: RecipientForCreation[]) => Promise | 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({ - 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 ( - - - - - Review detected recipients - - - - Confirm, edit, or add recipients before continuing. You can adjust any information - below before importing it into your document. - - - - -
- -
- {fields.map((field, index) => ( -
- ( - - {index === 0 && ( - - Email - - )} - - - handleRecipientAutoCompleteSelect(index, suggestion) - } - onSearchQueryChange={(query) => { - emailField.onChange(query); - setRecipientSearchQuery(query); - }} - loading={isLoading} - disabled={isSubmitting} - maxLength={254} - /> - - - - )} - /> - - ( - - {index === 0 && ( - - Name - - )} - - - handleRecipientAutoCompleteSelect(index, suggestion) - } - onSearchQueryChange={(query) => { - nameField.onChange(query); - setRecipientSearchQuery(query); - }} - loading={isLoading} - disabled={isSubmitting} - maxLength={255} - /> - - - - )} - /> - - ( - - {index === 0 && ( - - Role - - )} - - - - - - )} - /> - - -
- ))} - - - -
- -
-
- - -
- - -
-
-
- -
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/suggested-recipients-form.tsx b/apps/remix/app/components/dialogs/suggested-recipients-form.tsx new file mode 100644 index 000000000..ff60acde2 --- /dev/null +++ b/apps/remix/app/components/dialogs/suggested-recipients-form.tsx @@ -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; + +export type SuggestedRecipientsFormProps = { + recipients: RecipientForCreation[] | null; + onCancel: () => void; + onSubmit: (recipients: RecipientForCreation[]) => Promise | void; + onAutoAddFields?: (recipients: RecipientForCreation[]) => Promise | 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({ + 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 ( +
+ +
+ {fields.map((field, index) => ( +
+ ( + + {index === 0 && ( + + Email + + )} + + + handleRecipientAutoCompleteSelect(index, suggestion) + } + onSearchQueryChange={(query) => { + emailField.onChange(query); + setRecipientSearchQuery(query); + }} + loading={isLoading} + disabled={isSubmitting} + maxLength={254} + /> + + + + )} + /> + + ( + + {index === 0 && ( + + Name + + )} + + + handleRecipientAutoCompleteSelect(index, suggestion) + } + onSearchQueryChange={(query) => { + nameField.onChange(query); + setRecipientSearchQuery(query); + }} + loading={isLoading} + disabled={isSubmitting} + maxLength={255} + /> + + + + )} + /> + + ( + + {index === 0 && ( + + Role + + )} + + + + + + )} + /> + + +
+ ))} + + + +
+ +
+
+ + + + +
+ + + {onAutoAddFields && ( + + + +
+ +
+
+ {(fields.length === 0 || !form.formState.isValid) && ( + + Please add at least one valid recipient to auto-detect fields + + )} +
+
+ )} +
+
+
+ + ); +}; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index d44dd6eeb..6121e5f39 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -1,7 +1,7 @@ import { lazy, useEffect, useMemo, useState } from 'react'; 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 { FieldType, RecipientRole } from '@prisma/client'; import { FileTextIcon } from 'lucide-react'; @@ -54,7 +54,6 @@ const EnvelopeEditorFieldsPageRenderer = lazy( async () => import('./envelope-editor-fields-page-renderer'), ); -// Expands fields to minimum usable dimensions (30px height, 36px width) and centers them const enforceMinimumFieldDimensions = (params: { positionX: number; positionY: number; @@ -184,6 +183,7 @@ export const EnvelopeEditorFieldsPage = () => { current: number; total: number; } | null>(null); + const [hasAutoPlacedFields, setHasAutoPlacedFields] = useState(false); const selectedField = useMemo( () => structuredClone(editorFields.selectedField), @@ -204,9 +204,6 @@ export const EnvelopeEditorFieldsPage = () => { } }; - /** - * Set the selected recipient to the first recipient in the envelope. - */ useEffect(() => { const firstSelectableRecipient = envelope.recipients.find( (recipient) => @@ -216,16 +213,143 @@ export const EnvelopeEditorFieldsPage = () => { 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(); + 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 (
{/* Horizontal envelope item selector */} {isDetectingFields && ( <> -
-
-
-
+
+
+
+
)} @@ -330,7 +454,7 @@ export const EnvelopeEditorFieldsPage = () => { try { if (!currentEnvelopeItem) { toast({ - title: t`Error`, + title: t`No document selected`, description: t`No document selected. Please reload the page and try again.`, variant: 'destructive', }); @@ -339,7 +463,7 @@ export const EnvelopeEditorFieldsPage = () => { if (!currentEnvelopeItem.documentDataId) { toast({ - title: t`Error`, + title: t`Document data missing`, description: t`Document data not found. Please try reloading the page.`, variant: 'destructive', }); @@ -430,24 +554,24 @@ export const EnvelopeEditorFieldsPage = () => { } toast({ - title: t`Success`, + title: t`Fields added`, description, }); } else if (failedPages > 0) { toast({ - title: t`Error`, + title: t`Field detection failed`, description: t`Failed to detect fields on ${failedPages} pages. Please try again.`, variant: 'destructive', }); } else { toast({ - title: t`Info`, + title: t`No fields detected`, description: t`No fields were detected in the document`, }); } } catch (error) { toast({ - title: t`Error`, + title: t`Processing error`, description: t`An unexpected error occurred while processing pages.`, variant: 'destructive', }); diff --git a/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx b/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx index 4400a2984..d1e7323ea 100644 --- a/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx +++ b/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx @@ -1,5 +1,6 @@ import { type ReactNode, useState } from 'react'; +import { plural } from '@lingui/core/macro'; import { Trans, useLingui } from '@lingui/react/macro'; import { EnvelopeType } from '@prisma/client'; 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 { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog'; -import { SuggestedRecipientsDialog } from '~/components/dialogs/suggested-recipients-dialog'; import { useCurrentTeam } from '~/providers/team'; import { type RecipientForCreation, @@ -61,7 +61,6 @@ export const EnvelopeDropZoneWrapper = ({ const [showExtractionPrompt, setShowExtractionPrompt] = useState(false); const [uploadedDocumentId, setUploadedDocumentId] = useState(null); const [pendingRecipients, setPendingRecipients] = useState(null); - const [showRecipientsDialog, setShowRecipientsDialog] = useState(false); const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true); const userTimezone = @@ -124,7 +123,6 @@ export const EnvelopeDropZoneWrapper = ({ // Show AI prompt dialog for documents setUploadedDocumentId(id); setPendingRecipients(null); - setShowRecipientsDialog(false); setShouldNavigateAfterPromptClose(true); setShowExtractionPrompt(true); } else { @@ -148,7 +146,7 @@ export const EnvelopeDropZoneWrapper = ({ .otherwise(() => t`An error occurred during upload.`); toast({ - title: t`Error`, + title: t`Upload failed`, description: errorMessage, variant: 'destructive', duration: 7500, @@ -252,8 +250,6 @@ export const EnvelopeDropZoneWrapper = ({ setPendingRecipients(recipientsWithEmails); setShouldNavigateAfterPromptClose(false); - setShowExtractionPrompt(false); - setShowRecipientsDialog(true); } catch (error) { if (!(error instanceof Error && error.message === 'NO_RECIPIENTS_DETECTED')) { const parsedError = AppError.parseError(error); @@ -276,12 +272,6 @@ export const EnvelopeDropZoneWrapper = ({ navigateToEnvelopeEditor(); }; - const handleRecipientsCancel = () => { - setShowRecipientsDialog(false); - setPendingRecipients(null); - navigateToEnvelopeEditor(); - }; - const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => { if (!uploadedDocumentId) { return; @@ -295,11 +285,17 @@ export const EnvelopeDropZoneWrapper = ({ toast({ 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, }); - setShowRecipientsDialog(false); + setShowExtractionPrompt(false); setPendingRecipients(null); navigateToEnvelopeEditor(); } catch (error) { @@ -401,20 +397,8 @@ export const EnvelopeDropZoneWrapper = ({ onOpenChange={handlePromptDialogOpenChange} onAccept={handleStartRecipientDetection} onSkip={handleSkipRecipientDetection} - /> - - { - if (!open) { - handleRecipientsCancel(); - } else { - setShowRecipientsDialog(true); - } - }} - onCancel={handleRecipientsCancel} - onSubmit={handleRecipientsConfirm} + onRecipientsSubmit={handleRecipientsConfirm} />
); diff --git a/apps/remix/app/components/general/envelope/envelope-upload-button.tsx b/apps/remix/app/components/general/envelope/envelope-upload-button.tsx index 2a829ec85..d0f3bbe01 100644 --- a/apps/remix/app/components/general/envelope/envelope-upload-button.tsx +++ b/apps/remix/app/components/general/envelope/envelope-upload-button.tsx @@ -1,6 +1,6 @@ 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 { Trans } from '@lingui/react/macro'; import { EnvelopeType } from '@prisma/client'; @@ -28,8 +28,8 @@ import { import { useToast } from '@documenso/ui/primitives/use-toast'; import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog'; -import { SuggestedRecipientsDialog } from '~/components/dialogs/suggested-recipients-dialog'; import { useCurrentTeam } from '~/providers/team'; +import { detectFieldsInDocument } from '~/utils/detect-document-fields'; import { type RecipientForCreation, detectRecipientsInDocument, @@ -65,8 +65,8 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo const [showExtractionPrompt, setShowExtractionPrompt] = useState(false); const [uploadedDocumentId, setUploadedDocumentId] = useState(null); const [pendingRecipients, setPendingRecipients] = useState(null); - const [showRecipientsDialog, setShowRecipientsDialog] = useState(false); const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true); + const [isAutoAddingFields, setIsAutoAddingFields] = useState(false); const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation(); const { mutateAsync: createRecipients } = trpc.envelope.recipient.createMany.useMutation(); @@ -121,11 +121,9 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url); - // Show AI prompt dialog for documents only if (type === EnvelopeType.DOCUMENT) { setUploadedDocumentId(id); setPendingRecipients(null); - setShowRecipientsDialog(false); setShouldNavigateAfterPromptClose(true); setShowExtractionPrompt(true); @@ -135,7 +133,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo duration: 5000, }); } else { - // Templates - navigate immediately await navigate(`${pathPrefix}/${id}/edit`); toast({ @@ -162,7 +159,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo .otherwise(() => t`An error occurred while uploading your document.`); toast({ - title: t`Error`, + title: t`Upload failed`, description: errorMessage, variant: 'destructive', duration: 7500, @@ -229,12 +226,11 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo setPendingRecipients(recipientsWithEmails); setShouldNavigateAfterPromptClose(false); - setShowExtractionPrompt(false); - setShowRecipientsDialog(true); } 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({ title: t`Failed to analyze recipients`, 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(); }; - const handleRecipientsCancel = () => { - setShowRecipientsDialog(false); - setPendingRecipients(null); - navigateToEnvelopeEditor(); - }; - const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => { if (!uploadedDocumentId) { return; @@ -272,11 +262,17 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo toast({ 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, }); - setShowRecipientsDialog(false); + setShowExtractionPrompt(false); setPendingRecipients(null); navigateToEnvelopeEditor(); } catch (err) { @@ -289,7 +285,72 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo 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} onAccept={handleStartRecipientDetection} onSkip={handleSkipRecipientDetection} - /> - - { - if (!open) { - handleRecipientsCancel(); - } else { - setShowRecipientsDialog(true); - } - }} - onCancel={handleRecipientsCancel} - onSubmit={handleRecipientsConfirm} + onRecipientsSubmit={handleRecipientsConfirm} + onAutoAddFields={handleAutoAddFields} + isProcessingRecipients={isAutoAddingFields} />
); diff --git a/apps/remix/app/utils/detect-document-fields.ts b/apps/remix/app/utils/detect-document-fields.ts new file mode 100644 index 000000000..7f528963b --- /dev/null +++ b/apps/remix/app/utils/detect-document-fields.ts @@ -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 => { + 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; +}; diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css index f48180af5..4814b3d5f 100644 --- a/packages/ui/styles/theme.css +++ b/packages/ui/styles/theme.css @@ -200,22 +200,22 @@ @keyframes edgeGlow { 0%, 100% { - opacity: 0.3; + opacity: 0.6; } 50% { - opacity: 0.6; + opacity: 1; } } .edge-glow { - animation: edgeGlow 2s ease-in-out infinite; + animation: edgeGlow 1.25s ease-in-out infinite; } .edge-glow-top { background: linear-gradient( to bottom, - rgba(162, 231, 113, 0.4) 0%, - rgba(162, 231, 113, 0.2) 20%, + rgba(162, 231, 113, 0.8) 0%, + rgba(162, 231, 113, 0.4) 20%, transparent 100% ); } @@ -223,8 +223,8 @@ .edge-glow-right { background: linear-gradient( to left, - rgba(162, 231, 113, 0.4) 0%, - rgba(162, 231, 113, 0.2) 20%, + rgba(162, 231, 113, 0.8) 0%, + rgba(162, 231, 113, 0.4) 20%, transparent 100% ); } @@ -232,8 +232,8 @@ .edge-glow-bottom { background: linear-gradient( to top, - rgba(162, 231, 113, 0.4) 0%, - rgba(162, 231, 113, 0.2) 20%, + rgba(162, 231, 113, 0.8) 0%, + rgba(162, 231, 113, 0.4) 20%, transparent 100% ); } @@ -241,8 +241,8 @@ .edge-glow-left { background: linear-gradient( to right, - rgba(162, 231, 113, 0.4) 0%, - rgba(162, 231, 113, 0.2) 20%, + rgba(162, 231, 113, 0.8) 0%, + rgba(162, 231, 113, 0.4) 20%, transparent 100% ); }