chore: merge main, resolve biome formatting conflicts

Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
This commit is contained in:
ephraimduncan
2026-05-12 11:46:11 +00:00
parent 9194884fbe
commit 138d663c25
1959 changed files with 93488 additions and 47038 deletions
@@ -1,9 +1,3 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentDistributionMethod } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import type { Control } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
@@ -21,16 +15,17 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentDistributionMethod } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import type { Control } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
@@ -44,7 +39,7 @@ export const ConfigureDocumentAdvancedSettings = ({
control,
isSubmitting,
}: ConfigureDocumentAdvancedSettingsProps) => {
const { t } = useLingui();
const { _ } = useLingui();
const form = useFormContext<TConfigureEmbedFormSchema>();
const { features } = useConfigureDocument();
@@ -58,11 +53,11 @@ export const ConfigureDocumentAdvancedSettings = ({
return (
<div>
<h3 className="text-foreground mb-1 text-lg font-medium">
<h3 className="mb-1 font-medium text-foreground text-lg">
<Trans>Advanced Settings</Trans>
</h3>
<p className="text-muted-foreground mb-6 text-sm">
<p className="mb-6 text-muted-foreground text-sm">
<Trans>Configure additional options and preferences</Trans>
</p>
@@ -93,12 +88,12 @@ export const ConfigureDocumentAdvancedSettings = ({
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: t(option.label),
label: _(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
className="w-full bg-background"
emptySelectionPlaceholder={t`Select signature types`}
/>
</FormControl>
@@ -125,7 +120,7 @@ export const ConfigureDocumentAdvancedSettings = ({
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
{_(language.full)}
</SelectItem>
))}
</SelectContent>
@@ -202,10 +197,8 @@ export const ConfigureDocumentAdvancedSettings = ({
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
Add a URL to redirect the user to once the document is signed
</Trans>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>Add a URL to redirect the user to once the document is signed</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
@@ -250,8 +243,8 @@ export const ConfigureDocumentAdvancedSettings = ({
<FormDescription>
<Trans>
Choose how to distribute your document to recipients. Email will send
notifications, None will generate signing links for manual distribution.
Choose how to distribute your document to recipients. Email will send notifications, None will
generate signing links for manual distribution.
</Trans>
</FormDescription>
@@ -277,7 +270,7 @@ export const ConfigureDocumentAdvancedSettings = ({
<FormControl>
<Input
id="subject"
className="bg-background mt-2"
className="mt-2 bg-background"
disabled={isSubmitting || !isEmailDistribution}
{...field}
/>
@@ -300,7 +293,7 @@ export const ConfigureDocumentAdvancedSettings = ({
<FormControl>
<Textarea
id="message"
className="bg-background mt-2 h-32 resize-none"
className="mt-2 h-32 resize-none bg-background"
disabled={isSubmitting || !isEmailDistribution}
{...field}
/>
@@ -1,5 +1,10 @@
import { useCallback, useRef } from 'react';
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 { Checkbox } from '@documenso/ui/primitives/checkbox';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { msg } from '@lingui/core/macro';
@@ -9,23 +14,10 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVertical, HelpCircle, Plus, Trash } from 'lucide-react';
import { nanoid } from 'nanoid';
import { useCallback, useRef } from 'react';
import type { Control } from 'react-hook-form';
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
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 { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
@@ -37,10 +29,7 @@ export interface ConfigureDocumentRecipientsProps {
isSubmitting: boolean;
}
export const ConfigureDocumentRecipients = ({
control,
isSubmitting,
}: ConfigureDocumentRecipientsProps) => {
export const ConfigureDocumentRecipients = ({ control, isSubmitting }: ConfigureDocumentRecipientsProps) => {
const { _ } = useLingui();
const { isTemplate } = useConfigureDocument();
@@ -66,16 +55,14 @@ export const ConfigureDocumentRecipients = ({
});
const onAddSigner = useCallback(() => {
const recipientSigningOrder =
signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1;
const recipientSigningOrder = signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1;
appendSigner({
formId: nanoid(8),
name: '',
email: '',
role: RecipientRole.SIGNER,
signingOrder:
signingOrder === DocumentSigningOrder.SEQUENTIAL ? recipientSigningOrder : undefined,
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? recipientSigningOrder : undefined,
});
}, [appendSigner, signers]);
@@ -116,7 +103,9 @@ export const ConfigureDocumentRecipients = ({
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) return;
if (!result.destination) {
return;
}
// Use the move function from useFieldArray which preserves input values
move(result.source.index, result.destination.index);
@@ -146,11 +135,11 @@ export const ConfigureDocumentRecipients = ({
return (
<div>
<h3 className="text-foreground mb-1 text-lg font-medium">
<h3 className="mb-1 font-medium text-foreground text-lg">
<Trans>Recipients</Trans>
</h3>
<p className="text-muted-foreground mb-6 text-sm">
<p className="mb-6 text-muted-foreground text-sm">
<Trans>Add signers and configure signing preferences</Trans>
</p>
@@ -165,9 +154,7 @@ export const ConfigureDocumentRecipients = ({
id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) =>
onSigningOrderChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
)
onSigningOrderChange(checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL)
}
disabled={isSubmitting}
/>
@@ -206,15 +193,15 @@ export const ConfigureDocumentRecipients = ({
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircle className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>
When enabled, signers can choose who should sign next in the sequence instead
of following the predefined order.
When enabled, signers can choose who should sign next in the sequence instead of following the
predefined order.
</Trans>
</p>
</TooltipContent>
@@ -249,8 +236,7 @@ export const ConfigureDocumentRecipients = ({
{...provided.dragHandleProps}
disabled={signer.disabled}
className={cn('py-1', {
'bg-widget-foreground pointer-events-none rounded-md pt-2':
snapshot.isDragging,
'pointer-events-none rounded-md bg-widget-foreground pt-2': snapshot.isDragging,
})}
>
<motion.div
@@ -265,9 +251,7 @@ export const ConfigureDocumentRecipients = ({
render={({ field }) => (
<FormItem
className={cn('flex w-16 flex-none items-center gap-x-1', {
'mb-6':
errors?.signers?.[index] &&
!errors?.signers?.[index]?.signingOrder,
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.signingOrder,
})}
>
<GripVertical className="h-5 w-5 flex-shrink-0 opacity-40" />
@@ -325,8 +309,7 @@ export const ConfigureDocumentRecipients = ({
render={({ field }) => (
<FormItem
className={cn('flex-1', {
'mb-6':
errors?.signers?.[index] && !errors?.signers?.[index]?.email,
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.email,
})}
>
<FormLabel className="sr-only">
@@ -374,12 +357,7 @@ export const ConfigureDocumentRecipients = ({
<Button
type="button"
variant="ghost"
disabled={
isSubmitting ||
signers.length === 1 ||
snapshot.isDragging ||
signer.disabled
}
disabled={isSubmitting || signers.length === 1 || snapshot.isDragging || signer.disabled}
onClick={() => removeSigner(index)}
>
<Trash className="h-4 w-4" />
@@ -396,14 +374,8 @@ export const ConfigureDocumentRecipients = ({
</DragDropContext>
<div className="mt-4 flex justify-end">
<Button
type="button"
variant="outline"
className="w-auto"
disabled={isSubmitting}
onClick={onAddSigner}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
<Button type="button" variant="outline" className="w-auto" disabled={isSubmitting} onClick={onAddSigner}>
<Plus className="mr-2 -ml-1 h-5 w-5" />
<Trans>Add Signer</Trans>
</Button>
</div>
@@ -1,24 +1,17 @@
import { useState } from 'react';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Cloud, FileText, Loader, X } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { useState } from 'react';
import { type FileRejection, useDropzone } from 'react-dropzone';
import { useFormContext } from 'react-hook-form';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
@@ -82,10 +75,10 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
}
};
const onDropRejected = () => {
const onDropRejected = (fileRejections: FileRejection[]) => {
toast({
title: _(msg`Your document failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
description: _(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
@@ -106,11 +99,13 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
if (bytes === 0) {
return '0 Bytes';
}
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
@@ -144,11 +139,10 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
<div
{...getRootProps()}
className={cn(
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition',
'relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-border border-dashed bg-background transition',
{
'border-primary/50 bg-primary/5': isDragActive,
'hover:bg-muted/30':
!isDragActive && !isSubmitting && !isLoading && !isPersisted,
'hover:bg-muted/30': !isDragActive && !isSubmitting && !isLoading && !isPersisted,
'cursor-not-allowed opacity-60': isSubmitting || isLoading || isPersisted,
},
)}
@@ -169,7 +163,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
'text-muted-foreground': !isDragActive,
})}
>
<p className="text-sm font-medium">
<p className="font-medium text-sm">
{isDragActive ? (
<Trans>Drop your document here</Trans>
) : isPersisted ? (
@@ -182,9 +176,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
{isPersisted ? (
<Trans>This document cannot be changed</Trans>
) : (
<Trans>
.PDF documents accepted (max {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB)
</Trans>
<Trans>.PDF documents accepted (max {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB)</Trans>
)}
</p>
</div>
@@ -193,23 +185,21 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
</FormControl>
{isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/50">
<Loader className="h-10 w-10 animate-spin text-muted-foreground" />
</div>
)}
</div>
) : (
<div className="mt-2 rounded-lg border p-4">
<div className="flex items-center gap-x-4">
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md">
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-primary/10 text-primary">
<FileText className="h-6 w-6" />
</div>
<div className="flex-1">
<div className="text-sm font-medium">{documentData.name}</div>
<div className="text-muted-foreground text-xs">
{formatFileSize(documentData.size)}
</div>
<div className="font-medium text-sm">{documentData.name}</div>
<div className="text-muted-foreground text-xs">{formatFileSize(documentData.size)}</div>
</div>
{!isPersisted && (
@@ -1,23 +1,15 @@
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { nanoid } from 'nanoid';
import { useForm } from 'react-hook-form';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { ConfigureDocumentAdvancedSettings } from './configure-document-advanced-settings';
import { useConfigureDocument } from './configure-document-context';
import { ConfigureDocumentRecipients } from './configure-document-recipients';
@@ -45,9 +37,7 @@ export const ConfigureDocumentView = ({
const { isTemplate } = useConfigureDocument();
const form = useForm<TConfigureEmbedFormSchema>({
resolver: zodResolver(
type === 'template' ? ZConfigureTemplateEmbedFormSchema : ZConfigureEmbedFormSchema,
),
resolver: zodResolver(type === 'template' ? ZConfigureTemplateEmbedFormSchema : ZConfigureEmbedFormSchema),
defaultValues: {
title: defaultValues?.title || '',
signers: defaultValues?.signers || [
@@ -63,8 +53,7 @@ export const ConfigureDocumentView = ({
meta: {
subject: defaultValues?.meta?.subject || '',
message: defaultValues?.meta?.message || '',
distributionMethod:
defaultValues?.meta?.distributionMethod || DocumentDistributionMethod.EMAIL,
distributionMethod: defaultValues?.meta?.distributionMethod || DocumentDistributionMethod.EMAIL,
emailSettings: defaultValues?.meta?.emailSettings || ZDocumentEmailSettingsSchema.parse({}),
dateFormat: defaultValues?.meta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT,
timezone: defaultValues?.meta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE,
@@ -88,7 +77,7 @@ export const ConfigureDocumentView = ({
return (
<div className="flex w-full flex-col space-y-8">
<div>
<h2 className="text-foreground mb-1 text-xl font-semibold">
<h2 className="mb-1 font-semibold text-foreground text-xl">
{isTemplate ? <Trans>Configure Template</Trans> : <Trans>Configure Document</Trans>}
</h2>
@@ -126,12 +115,7 @@ export const ConfigureDocumentView = ({
<ConfigureDocumentAdvancedSettings control={control} isSubmitting={isSubmitting} />
<div className="flex justify-end">
<Button
type="button"
onClick={onFormSubmit}
disabled={isSubmitting}
className="w-full sm:w-auto"
>
<Button type="button" onClick={onFormSubmit} disabled={isSubmitting} className="w-full sm:w-auto">
<Trans>Continue</Trans>
</Button>
</div>
@@ -1,12 +1,9 @@
import { z } from 'zod';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaLanguageSchema,
} from '@documenso/lib/types/document-meta';
import { ZDocumentMetaDateFormatSchema, ZDocumentMetaLanguageSchema } from '@documenso/lib/types/document-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { zEmail } from '@documenso/lib/utils/zod';
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
import { z } from 'zod';
// Define the schema for configuration
export type TConfigureEmbedFormSchema = z.infer<typeof ZConfigureEmbedFormSchema>;
@@ -19,7 +16,7 @@ export const ZConfigureEmbedFormSchema = z.object({
nativeId: z.number().optional(),
formId: z.string(),
name: z.string(),
email: z.string().email('Invalid email address'),
email: zEmail('Invalid email address'),
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(),
disabled: z.boolean().optional(),
@@ -1,22 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { base64 } from '@scure/base';
import { ChevronsUpDown } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getPdfPagesCount, PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
@@ -24,14 +14,24 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
import { Form } from '@documenso/ui/primitives/form/form';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem, FieldType } from '@prisma/client';
import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { ChevronsUpDown } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { FieldAdvancedSettingsDrawer } from '~/components/embed/authoring/field-advanced-settings-drawer';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
import type { TConfigureFieldsFormSchema } from './configure-fields-view.types';
import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer';
const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36;
@@ -42,7 +42,7 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type ConfigureFieldsViewProps = {
configData: TConfigureEmbedFormSchema;
presignToken?: string | undefined;
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>;
defaultValues?: Partial<TConfigureFieldsFormSchema>;
onBack?: (data: TConfigureFieldsFormSchema) => void;
onSubmit: (data: TConfigureFieldsFormSchema) => void;
@@ -86,26 +86,25 @@ export const ConfigureFieldsView = ({
const normalizedDocumentData = useMemo(() => {
if (envelopeItem) {
return undefined;
return getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItem.envelopeId,
envelopeItemId: envelopeItem.id,
documentDataId: envelopeItem.documentDataId,
version: 'current',
token: undefined,
presignToken,
});
}
if (!configData.documentData) {
return undefined;
}
return base64.encode(configData.documentData.data);
}, [configData.documentData]);
const normalizedEnvelopeItem = useMemo(() => {
if (envelopeItem) {
return envelopeItem;
}
return { id: '', envelopeId: '' };
}, [envelopeItem]);
return configData.documentData.data;
}, [configData.documentData, envelopeItem, presignToken]);
const recipients = useMemo(() => {
return configData.signers.map<Recipient>((signer, index) => ({
return configData.signers.map<TRecipientLite>((signer, index) => ({
id: signer.nativeId || index,
name: signer.name || '',
email: signer.email || '',
@@ -115,7 +114,9 @@ export const ConfigureFieldsView = ({
templateId: null,
token: '',
documentDeletedAt: null,
expired: null,
expired: null, // !: deprecated Not in use. To be removed in a future migration.
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
rejectionReason: null,
@@ -126,7 +127,7 @@ export const ConfigureFieldsView = ({
}));
}, [configData.signers]);
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>(
const [selectedRecipient, setSelectedRecipient] = useState<TRecipientLite | null>(
() => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null,
);
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
@@ -136,16 +137,10 @@ export const ConfigureFieldsView = ({
y: 0,
});
const [activeFieldId, setActiveFieldId] = useState<string | null>(null);
const [lastActiveField, setLastActiveField] = useState<
TConfigureFieldsFormSchema['fields'][0] | null
>(null);
const [fieldClipboard, setFieldClipboard] = useState<
TConfigureFieldsFormSchema['fields'][0] | null
>(null);
const [lastActiveField, setLastActiveField] = useState<TConfigureFieldsFormSchema['fields'][0] | null>(null);
const [fieldClipboard, setFieldClipboard] = useState<TConfigureFieldsFormSchema['fields'][0] | null>(null);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [currentField, setCurrentField] = useState<TConfigureFieldsFormSchema['fields'][0] | null>(
null,
);
const [currentField, setCurrentField] = useState<TConfigureFieldsFormSchema['fields'][0] | null>(null);
const fieldBounds = useRef({
height: DEFAULT_HEIGHT_PX,
@@ -153,9 +148,7 @@ export const ConfigureFieldsView = ({
});
const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id);
const selectedRecipientStyles = useRecipientColors(
selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex,
);
const selectedRecipientStyles = getRecipientColorStyles(selectedRecipientIndex);
const form = useForm<TConfigureFieldsFormSchema>({
defaultValues: {
@@ -177,8 +170,6 @@ export const ConfigureFieldsView = ({
name: 'fields',
});
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
@@ -203,13 +194,15 @@ export const ConfigureFieldsView = ({
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
const totalPages = getPdfPagesCount();
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (totalPages < 1) {
return;
}
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
if (pageNumber === lastActiveField.pageNumber) {
return;
continue;
}
const newField: TConfigureFieldsFormSchema['fields'][0] = {
@@ -222,7 +215,7 @@ export const ConfigureFieldsView = ({
};
append(newField);
});
}
return;
}
@@ -265,15 +258,12 @@ export const ConfigureFieldsView = ({
const onMouseMove = useCallback(
(event: MouseEvent) => {
if (!selectedField) return;
if (!selectedField) {
return;
}
setIsFieldWithinBounds(
isWithinPageBounds(
event,
PDF_VIEWER_PAGE_SELECTOR,
fieldBounds.current.width,
fieldBounds.current.height,
),
isWithinPageBounds(event, PDF_VIEWER_PAGE_SELECTOR, fieldBounds.current.width, fieldBounds.current.height),
);
setCoords({
@@ -294,12 +284,7 @@ export const ConfigureFieldsView = ({
if (
!$page ||
!isWithinPageBounds(
event,
PDF_VIEWER_PAGE_SELECTOR,
fieldBounds.current.width,
fieldBounds.current.height,
)
!isWithinPageBounds(event, PDF_VIEWER_PAGE_SELECTOR, fieldBounds.current.width, fieldBounds.current.height)
) {
return;
}
@@ -358,12 +343,7 @@ export const ConfigureFieldsView = ({
return;
}
const {
x: pageX,
y: pageY,
width: pageWidth,
height: pageHeight,
} = getFieldPosition($page, node);
const { x: pageX, y: pageY, width: pageWidth, height: pageHeight } = getFieldPosition($page, node);
update(index, {
...field,
@@ -465,11 +445,11 @@ export const ConfigureFieldsView = ({
{!isMobile && (
<div className="order-2 col-span-12 md:order-1 md:col-span-4">
<div className="sticky top-4 max-h-[calc(100vh-2rem)] rounded-lg border border-border bg-widget p-4 pb-6">
<h2 className="mb-1 text-lg font-medium">
<h2 className="mb-1 font-medium text-lg">
<Trans>Configure Fields</Trans>
</h2>
<p className="mb-6 text-sm text-muted-foreground">
<p className="mb-6 text-muted-foreground text-sm">
<Trans>Configure the fields you want to place on the document.</Trans>
</p>
@@ -523,7 +503,7 @@ export const ConfigureFieldsView = ({
{selectedField && (
<div
className={cn(
'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size]',
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size] dark:text-muted-background',
selectedRecipientStyles.base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
@@ -538,25 +518,15 @@ export const ConfigureFieldsView = ({
width: fieldBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
{_(FRIENDLY_FIELD_TYPE[selectedField])}
</span>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{_(FRIENDLY_FIELD_TYPE[selectedField])}</span>
</div>
)}
<Form {...form}>
<div>
<PDFViewerLazy
presignToken={presignToken}
overrideData={normalizedDocumentData}
envelopeItem={normalizedEnvelopeItem}
token={undefined}
version="signed"
/>
{normalizedDocumentData && <PDFViewerLazy data={normalizedDocumentData} scrollParentRef="window" />}
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
@@ -598,8 +568,8 @@ export const ConfigureFieldsView = ({
{isMobile && (
<Sheet open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<SheetTrigger asChild>
<div className="fixed bottom-6 left-6 right-6 z-50 flex items-center justify-between gap-2 rounded-lg border border-border bg-widget p-4">
<span className="text-lg font-medium">
<div className="fixed right-6 bottom-6 left-6 z-50 flex items-center justify-between gap-2 rounded-lg border border-border bg-widget p-4">
<span className="font-medium text-lg">
<Trans>Configure Fields</Trans>
</span>
@@ -617,11 +587,11 @@ export const ConfigureFieldsView = ({
size="xl"
className="h-fit max-h-[80vh] overflow-y-auto rounded-t-xl bg-widget p-4"
>
<h2 className="mb-1 text-lg font-medium">
<h2 className="mb-1 font-medium text-lg">
<Trans>Configure Fields</Trans>
</h2>
<p className="mb-6 text-sm text-muted-foreground">
<p className="mb-6 text-muted-foreground text-sm">
<Trans>Configure the fields you want to place on the document.</Trans>
</p>
@@ -1,7 +1,6 @@
import { z } from 'zod';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
import { z } from 'zod';
export const ZConfigureFieldsFormSchema = z.object({
fields: z.array(
@@ -24,6 +23,4 @@ export const ZConfigureFieldsFormSchema = z.object({
export type TConfigureFieldsFormSchema = z.infer<typeof ZConfigureFieldsFormSchema>;
export type TConfigureFieldsFormSchemaField = z.infer<
typeof ZConfigureFieldsFormSchema
>['fields'][number];
export type TConfigureFieldsFormSchemaField = z.infer<typeof ZConfigureFieldsFormSchema>['fields'][number];
@@ -1,11 +1,10 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
import type { TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { FieldAdvancedSettings } from '@documenso/ui/primitives/document-flow/field-item-advanced-settings';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { TConfigureFieldsFormSchemaField } from './configure-fields-view.types';
@@ -42,10 +41,7 @@ export const FieldAdvancedSettingsDrawer = ({
<FieldAdvancedSettings
title={msg`Advanced settings`}
description={msg`Configure the ${parseMessageDescriptor(
_,
FRIENDLY_FIELD_TYPE[currentField.type],
)} field`}
description={msg`Configure the ${parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[currentField.type])} field`}
field={currentField}
fields={fields}
onAdvancedSettings={() => onOpenChange(false)}
@@ -1,6 +1,5 @@
import { Trans } from '@lingui/react/macro';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Trans } from '@lingui/react/macro';
import { SignInForm } from '~/components/forms/signin';
import { BrandingLogo } from '~/components/general/branding-logo';
@@ -29,10 +28,7 @@ export const EmbedAuthenticationRequired = ({
<Alert className="mt-8" variant="warning">
<AlertDescription>
<Trans>
To view this document you need to be signed into your account, please sign in to
continue.
</Trans>
<Trans>To view this document you need to be signed into your account, please sign in to continue.</Trans>
</AlertDescription>
</Alert>
@@ -1,11 +1,14 @@
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
export const EmbedClientLoading = () => {
return (
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
<div className="fixed top-0 left-0 z-[9999] flex h-full w-full items-center justify-center bg-background">
<Loader className="mr-2 h-4 w-4 animate-spin" />
<span>Loading...</span>
<span>
<Trans>Loading...</Trans>
</span>
</div>
);
};
@@ -1,5 +1,28 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema';
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { zEmail } from '@documenso/lib/utils/zod';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
@@ -13,34 +36,11 @@ import {
} from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon';
import { useEffect, useLayoutEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
isFieldUnsignedAndRequired,
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { BrandingLogo } from '~/components/general/branding-logo';
import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { injectCss } from '~/utils/css-vars';
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
@@ -54,7 +54,7 @@ export type EmbedDirectTemplateClientPageProps = {
token: string;
envelopeId: string;
updatedAt: Date;
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>[];
recipient: Recipient;
fields: Field[];
metadata?: DocumentMeta | null;
@@ -78,8 +78,7 @@ export const EmbedDirectTemplateClientPage = ({
const [searchParams] = useSearchParams();
const { fullName, email, signature, setFullName, setEmail, setSignature } =
useRequiredDocumentSigningContext();
const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
@@ -91,18 +90,17 @@ export const EmbedDirectTemplateClientPage = ({
const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [emailError, setEmailError] = useState<string | null>(null);
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
const [pendingFields, _completedFields] = [
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
sortFieldsByPosition(localFields.filter((field) => isFieldUnsignedAndRequired(field))),
localFields.filter((field) => field.inserted),
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
@@ -130,10 +128,8 @@ export const EmbedDirectTemplateClientPage = ({
created: new Date(),
recipientId: 1,
fieldId: 1,
signatureImageAsBase64:
payload.value && payload.value.startsWith('data:') ? payload.value : null,
typedSignature:
payload.value && !payload.value.startsWith('data:') ? payload.value : null,
signatureImageAsBase64: payload.value && payload.value.startsWith('data:') ? payload.value : null,
typedSignature: payload.value && !payload.value.startsWith('data:') ? payload.value : null,
} satisfies Signature;
}
@@ -206,6 +202,14 @@ export const EmbedDirectTemplateClientPage = ({
return;
}
const { success: isEmailValid } = zEmail().safeParse(email);
if (!isEmailValid) {
setEmailError(_(msg`A valid email is required`));
setIsExpanded(true);
return;
}
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
if (directTemplateExternalId) {
@@ -257,9 +261,7 @@ export const EmbedDirectTemplateClientPage = ({
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We were unable to submit this document at this time. Please try again later.`,
),
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
variant: 'destructive',
});
}
@@ -291,12 +293,19 @@ export const EmbedDirectTemplateClientPage = ({
cssVars: data.cssVars,
});
}
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
void dynamicActivate(data.language).finally(() => {
setHasFinishedInit(true);
});
} else {
setHasFinishedInit(true);
}
} catch (err) {
console.error(err);
setHasFinishedInit(true);
}
setHasFinishedInit(true);
// !: While the two setters are stable we still want to ensure we're avoiding
// !: re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -342,9 +351,15 @@ export const EmbedDirectTemplateClientPage = ({
{/* Viewer */}
<div className="flex-1">
<PDFViewerLazy
envelopeItem={envelopeItems[0]}
token={recipient.token}
version="signed"
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItems[0]?.envelopeId,
envelopeItemId: envelopeItems[0]?.id,
documentDataId: envelopeItems[0]?.documentDataId,
version: 'current',
token: recipient.token,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
@@ -352,31 +367,23 @@ export const EmbedDirectTemplateClientPage = ({
{/* Widget */}
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:bottom-[unset] md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="flex h-fit w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
{/* Header */}
<div>
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-xl font-semibold text-foreground md:text-2xl">
<h3 className="font-semibold text-foreground text-xl md:text-2xl">
<Trans>Sign document</Trans>
</h3>
{isExpanded ? (
<Button
variant="outline"
className="h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)}
>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden" onClick={() => setIsExpanded(false)}>
<LucideChevronDown className="h-5 w-5 text-muted-foreground" />
</Button>
) : pendingFields.length > 0 ? (
<Button
variant="outline"
className="h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(true)}
>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden" onClick={() => setIsExpanded(true)}>
<LucideChevronUp className="h-5 w-5 text-muted-foreground" />
</Button>
) : (
@@ -395,11 +402,11 @@ export const EmbedDirectTemplateClientPage = ({
</div>
<div className="hidden group-data-[expanded]/document-widget:block md:block">
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-2 text-muted-foreground text-sm">
<Trans>Sign the document to complete the process.</Trans>
</p>
<hr className="mb-8 mt-4 border-border" />
<hr className="mt-4 mb-8 border-border" />
</div>
{/* Form */}
@@ -428,11 +435,18 @@ export const EmbedDirectTemplateClientPage = ({
<Input
type="email"
id="email"
className="mt-2 bg-background"
className={cn('mt-2 bg-background', emailError && 'border-destructive ring-2 ring-destructive/20')}
disabled={isEmailLocked}
value={email}
onChange={(e) => !isEmailLocked && setEmail(e.target.value.trim())}
onChange={(e) => {
if (!isEmailLocked) {
setEmail(e.target.value.trim());
setEmailError(null);
}
}}
/>
{emailError && <p className="mt-2 font-medium text-destructive text-xs">{emailError}</p>}
</div>
{hasSignatureField && (
@@ -478,15 +492,13 @@ export const EmbedDirectTemplateClientPage = ({
</div>
</div>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
{showPendingFieldTooltip && pendingFields.length > 0 && (
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}>
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
</ElementVisible>
)}
{/* Fields */}
<EmbedDocumentFields
@@ -498,8 +510,10 @@ export const EmbedDirectTemplateClientPage = ({
</div>
{!hidePoweredBy && (
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
<span>Powered by</span>
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 font-medium text-primary-foreground text-xs opacity-60 hover:opacity-100">
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -1,8 +1,7 @@
import { Trans } from '@lingui/react/macro';
import type { Signature } from '@prisma/client';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { Trans } from '@lingui/react/macro';
import type { Signature } from '@prisma/client';
export type EmbedDocumentCompletedPageProps = {
name?: string;
@@ -12,7 +11,7 @@ export type EmbedDocumentCompletedPageProps = {
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
return (
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-2xl font-semibold">
<h3 className="font-semibold text-2xl text-foreground">
<Trans>Document Completed!</Trans>
</h3>
@@ -25,10 +24,9 @@ export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentComplet
/>
</div>
<p className="text-muted-foreground mt-8 max-w-[50ch] text-center text-sm">
<p className="mt-8 max-w-[50ch] text-center text-muted-foreground text-sm">
<Trans>
The document is now completed, please follow any instructions provided within the parent
application.
The document is now completed, please follow any instructions provided within the parent application.
</Trans>
</p>
</div>
@@ -1,7 +1,3 @@
import type { DocumentMeta } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
@@ -18,6 +14,9 @@ import type {
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import type { DocumentMeta } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import { match } from 'ts-pattern';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
@@ -34,26 +33,15 @@ export type EmbedDocumentFieldsProps = {
fields: Field[];
metadata?: Pick<
DocumentMeta,
| 'timezone'
| 'dateFormat'
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
'timezone' | 'dateFormat' | 'typedSignatureEnabled' | 'uploadSignatureEnabled' | 'drawSignatureEnabled'
> | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const EmbedDocumentFields = ({
fields,
metadata,
onSignField,
onUnsignField,
}: EmbedDocumentFieldsProps) => {
const highestPageNumber = Math.max(...fields.map((field) => field.page));
export const EmbedDocumentFields = ({ fields, metadata, onSignField, onUnsignField }: EmbedDocumentFieldsProps) => {
return (
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
@@ -6,25 +6,25 @@ export const EmbedDocumentRejected = () => {
<div className="embed--DocumentRejected relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<XCircle className="text-destructive h-10 w-10" />
<XCircle className="h-10 w-10 text-destructive" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Rejected</Trans>
</h2>
</div>
<div className="text-destructive mt-4 flex items-center text-center text-sm">
<div className="mt-4 flex items-center text-center text-destructive text-sm">
<Trans>You have rejected this document</Trans>
</div>
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>
The document owner has been notified of your decision. They may contact you with further
instructions if necessary.
The document owner has been notified of your decision. They may contact you with further instructions if
necessary.
</Trans>
</p>
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
<p className="mt-2 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>No further action is required from you at this time.</Trans>
</p>
</div>
@@ -1,16 +1,11 @@
import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
@@ -23,15 +18,21 @@ import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
import { BrandingLogo } from '~/components/general/branding-logo';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { injectCss } from '~/utils/css-vars';
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
@@ -45,7 +46,7 @@ export type EmbedSignDocumentV1ClientPageProps = {
token: string;
documentId: number;
envelopeId: string;
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
envelopeItems: (Pick<EnvelopeItem, 'id' | 'envelopeId'> & { documentData: { id: string } })[];
recipient: RecipientWithFields;
fields: Field[];
completedFields: DocumentField[];
@@ -73,24 +74,21 @@ export const EmbedSignDocumentV1ClientPage = ({
const { _ } = useLingui();
const { toast } = useToast();
const { fullName, email, signature, setFullName, setSignature } =
useRequiredDocumentSigningContext();
const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [hasRejectedDocument, setHasRejectedDocument] = useState(
recipient.signingStatus === SigningStatus.REJECTED,
);
const [hasRejectedDocument, setHasRejectedDocument] = useState(recipient.signingStatus === SigningStatus.REJECTED);
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
allRecipients.length > 0 ? allRecipients[0].id : null,
);
const [isExpanded, setIsExpanded] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [isEmailLocked, setIsEmailLocked] = useState(!!email);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
useState(false);
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
@@ -100,21 +98,16 @@ export const EmbedSignDocumentV1ClientPage = ({
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [pendingFields, _completedFields] = [
fields.filter(
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
sortFieldsByPosition(
fields.filter((field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field)),
),
fields.filter((field) => field.inserted),
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
trpc.recipient.completeDocumentWithToken.useMutation();
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
[fields],
);
const fieldsRequiringValidation = useMemo(() => fields.filter(isFieldUnsignedAndRequired), [fields]);
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
@@ -171,9 +164,7 @@ export const EmbedSignDocumentV1ClientPage = ({
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We were unable to submit this document at this time. Please try again later.`,
),
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
variant: 'destructive',
});
}
@@ -204,13 +195,19 @@ export const EmbedSignDocumentV1ClientPage = ({
try {
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
if (!isCompleted && data.name) {
if (!isCompleted && data.name && !fullName) {
setFullName(data.name);
}
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
if (!isCompleted && data.email && !email) {
setEmail(data.email);
setIsEmailLocked(!!data.lockEmail);
}
setAllowDocumentRejection(!!data.allowDocumentRejection);
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
@@ -224,12 +221,19 @@ export const EmbedSignDocumentV1ClientPage = ({
cssVars: data.cssVars,
});
}
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
void dynamicActivate(data.language).finally(() => {
setHasFinishedInit(true);
});
} else {
setHasFinishedInit(true);
}
} catch (err) {
console.error(err);
setHasFinishedInit(true);
}
setHasFinishedInit(true);
// !: While the two setters are stable we still want to ensure we're avoiding
// !: re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -276,11 +280,7 @@ export const EmbedSignDocumentV1ClientPage = ({
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
{allowDocumentRejection && (
<DocumentSigningRejectDialog
documentId={documentId}
token={token}
onRejected={onDocumentRejected}
/>
<DocumentSigningRejectDialog documentId={documentId} token={token} onRejected={onDocumentRejected} />
)}
</div>
@@ -288,9 +288,15 @@ export const EmbedSignDocumentV1ClientPage = ({
{/* Viewer */}
<div className="embed--DocumentViewer flex-1">
<PDFViewerLazy
envelopeItem={envelopeItems[0]}
token={token}
version="signed"
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItems[0]?.envelopeId,
envelopeItemId: envelopeItems[0]?.id,
documentDataId: envelopeItems[0]?.documentData.id,
version: 'current',
token: token,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
@@ -298,19 +304,15 @@ export const EmbedSignDocumentV1ClientPage = ({
{/* Widget */}
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:bottom-[unset] md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="embed--DocumentWidget flex w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:py-6">
{/* Header */}
<div className="embed--DocumentWidgetHeader">
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-xl font-semibold text-foreground md:text-2xl">
{isAssistantMode ? (
<Trans>Assist with signing</Trans>
) : (
<Trans>Sign document</Trans>
)}
<h3 className="font-semibold text-foreground text-xl md:text-2xl">
{isAssistantMode ? <Trans>Assist with signing</Trans> : <Trans>Sign document</Trans>}
</h3>
{isExpanded ? (
@@ -334,9 +336,7 @@ export const EmbedSignDocumentV1ClientPage = ({
variant="default"
size="sm"
className="md:hidden"
disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
disabled={isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
@@ -347,7 +347,7 @@ export const EmbedSignDocumentV1ClientPage = ({
</div>
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-2 text-muted-foreground text-sm">
{isAssistantMode ? (
<Trans>Help complete the document for other signers.</Trans>
) : (
@@ -355,7 +355,7 @@ export const EmbedSignDocumentV1ClientPage = ({
)}
</p>
<hr className="mb-8 mt-4 border-border" />
<hr className="mt-4 mb-8 border-border" />
</div>
{/* Form */}
@@ -396,15 +396,13 @@ export const EmbedSignDocumentV1ClientPage = ({
{r.name}
{r.id === recipient.id && (
<span className="ml-2 text-muted-foreground">
{_(msg`(You)`)}
</span>
<span className="ml-2 text-muted-foreground">{_(msg`(You)`)}</span>
)}
</Label>
<p className="text-xs text-muted-foreground">{r.email}</p>
<p className="text-muted-foreground text-xs">{r.email}</p>
</div>
</div>
<div className="text-xs leading-[inherit] text-muted-foreground">
<div className="text-muted-foreground text-xs leading-[inherit]">
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
</div>
</div>
@@ -442,7 +440,8 @@ export const EmbedSignDocumentV1ClientPage = ({
id="email"
className="mt-2 bg-background"
value={email}
disabled
onChange={(e) => setEmail(e.target.value)}
disabled={isEmailLocked}
/>
</div>
@@ -491,15 +490,13 @@ export const EmbedSignDocumentV1ClientPage = ({
</div>
</div>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
{showPendingFieldTooltip && pendingFields.length > 0 && (
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}>
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
</ElementVisible>
)}
{/* Fields */}
<EmbedDocumentFields fields={fields} metadata={metadata} />
@@ -509,8 +506,10 @@ export const EmbedSignDocumentV1ClientPage = ({
</div>
{!hidePoweredBy && (
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
<span>Powered by</span>
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 font-medium text-primary-foreground text-xs opacity-60 hover:opacity-100">
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -1,10 +1,11 @@
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { useLingui } from '@lingui/react';
import { EnvelopeType } from '@prisma/client';
import { useEffect, useLayoutEffect, useState } from 'react';
import { useLingui } from '@lingui/react';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema';
import { injectCss } from '~/utils/css-vars';
import { DocumentSigningPageViewV2 } from '../general/document-signing/document-signing-page-view-v2';
@@ -25,7 +26,7 @@ export const EmbedSignDocumentV2ClientPage = ({
}: EmbedSignDocumentV2ClientPageProps) => {
const { _ } = useLingui();
const { envelope, recipient, envelopeData, setFullName, fullName } =
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } =
useRequiredEnvelopeSigningContext();
const { isCompleted, isRejected, recipientSignature } = envelopeData;
@@ -35,6 +36,7 @@ export const EmbedSignDocumentV2ClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [isEmailLocked, setIsEmailLocked] = useState(envelope.type === EnvelopeType.DOCUMENT && !!email);
const onDocumentCompleted = (data: {
token: string;
@@ -126,12 +128,26 @@ export const EmbedSignDocumentV2ClientPage = ({
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
if (!isCompleted && data.name) {
setFullName(data.name);
// For documents, only use the hash name if the recipient doesn't already have one.
// For templates, always allow the hash name to be used.
if (envelope.type === EnvelopeType.TEMPLATE || !fullName) {
setFullName(data.name);
}
}
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
if (!isCompleted && data.email) {
// For documents, only use the hash email if the recipient doesn't already have one.
// For templates, always allow the hash email to be used.
if (envelope.type === EnvelopeType.TEMPLATE || !email) {
setEmail(data.email);
setIsEmailLocked(!!data.lockEmail);
}
}
setAllowDocumentRejection(!!data.allowDocumentRejection);
if (data.darkModeDisabled) {
@@ -144,12 +160,19 @@ export const EmbedSignDocumentV2ClientPage = ({
cssVars: data.cssVars,
});
}
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
void dynamicActivate(data.language).finally(() => {
setHasFinishedInit(true);
});
} else {
setHasFinishedInit(true);
}
} catch (err) {
console.error(err);
setHasFinishedInit(true);
}
setHasFinishedInit(true);
// !: While the setters are stable we still want to ensure we're avoiding
// !: re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -213,6 +236,7 @@ export const EmbedSignDocumentV2ClientPage = ({
return (
<EmbedSigningProvider
isNameLocked={isNameLocked}
isEmailLocked={isEmailLocked}
hidePoweredBy={hidePoweredBy}
allowDocumentRejection={allowDocumentRejection}
onDocumentCompleted={onDocumentCompleted}
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
export const EmbedDocumentWaitingForTurn = () => {
const [hasPostedMessage, setHasPostedMessage] = useState(false);
@@ -25,19 +24,19 @@ export const EmbedDocumentWaitingForTurn = () => {
return (
<div className="embed--WaitingForTurn relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-center text-2xl font-bold">
<h3 className="text-center font-bold text-2xl text-foreground">
<Trans>Waiting for Your Turn</Trans>
</h3>
<div className="mt-8 max-w-[50ch] text-center">
<p className="text-muted-foreground text-sm">
<Trans>
It's currently not your turn to sign. Please check back soon as this document should be
available for you to sign shortly.
It's currently not your turn to sign. Please check back soon as this document should be available for you to
sign shortly.
</Trans>
</p>
<p className="text-muted-foreground mt-4 text-sm">
<p className="mt-4 text-muted-foreground text-sm">
<Trans>Please check with the parent application for more information.</Trans>
</p>
</div>
@@ -1,7 +1,24 @@
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
export const EmbedPaywall = () => {
return (
<div>
<h1>Paywall</h1>
<div className="flex min-h-screen items-center justify-center p-4">
<div className="text-center text-muted-foreground">
<p className="font-semibold text-lg">
<Trans>This feature is not available on your current plan</Trans>
</p>
<p className="mt-2 text-sm">
<Trans>
Please contact{' '}
<Link to={`mailto:${SUPPORT_EMAIL}`} target="_blank">
support
</Link>{' '}
if you have any questions.
</Trans>
</p>
</div>
</div>
);
};
@@ -0,0 +1,44 @@
import { Trans } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
export const EmbedRecipientExpired = () => {
const [hasPostedMessage, setHasPostedMessage] = useState(false);
useEffect(() => {
if (window.parent && !hasPostedMessage) {
window.parent.postMessage(
{
action: 'recipient-expired',
data: null,
},
'*',
);
}
setHasPostedMessage(true);
}, [hasPostedMessage]);
if (!hasPostedMessage) {
return null;
}
return (
<div className="embed--RecipientExpired relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-center font-bold text-2xl text-foreground">
<Trans>Signing Window Expired</Trans>
</h3>
<div className="mt-8 max-w-[50ch] text-center">
<p className="text-muted-foreground text-sm">
<Trans>
Your signing window for this document has expired. Please contact the sender for a new invitation.
</Trans>
</p>
<p className="mt-4 text-muted-foreground text-sm">
<Trans>Please check with the parent application for more information.</Trans>
</p>
</div>
</div>
);
};
@@ -6,12 +6,7 @@ export type EmbedSigningContextValue = {
isNameLocked: boolean;
isEmailLocked: boolean;
hidePoweredBy: boolean;
onDocumentCompleted: (data: {
token: string;
documentId: number;
envelopeId: string;
recipientId: number;
}) => void;
onDocumentCompleted: (data: { token: string; documentId: number; envelopeId: string; recipientId: number }) => void;
onDocumentError: () => void;
onDocumentRejected: (data: {
token: string;
@@ -46,12 +41,7 @@ export type EmbedSigningProviderProps = {
isNameLocked?: boolean;
isEmailLocked?: boolean;
hidePoweredBy?: boolean;
onDocumentCompleted: (data: {
token: string;
documentId: number;
envelopeId: string;
recipientId: number;
}) => void;
onDocumentCompleted: (data: { token: string; documentId: number; envelopeId: string; recipientId: number }) => void;
onDocumentError: () => void;
onDocumentRejected: (data: {
token: string;
@@ -1,14 +1,13 @@
import { Trans } from '@lingui/react/macro';
import { ReadStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { ArrowRight, EyeIcon, XCircle } from 'lucide-react';
import { match } from 'ts-pattern';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Progress } from '@documenso/ui/primitives/progress';
import { Trans } from '@lingui/react/macro';
import { ReadStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { ArrowRight, EyeIcon, XCircle } from 'lucide-react';
import { match } from 'ts-pattern';
// Get the return type from getRecipientByToken
type RecipientWithFields = Awaited<ReturnType<typeof getRecipientByToken>>;
@@ -25,9 +24,7 @@ interface MultiSignDocumentListProps {
export function MultiSignDocumentList({ envelopes, onDocumentSelect }: MultiSignDocumentListProps) {
// Calculate progress
const completedDocuments = envelopes.filter(
(envelope) => envelope.recipient.signingStatus === SigningStatus.SIGNED,
);
const completedDocuments = envelopes.filter((envelope) => envelope.recipient.signingStatus === SigningStatus.SIGNED);
const totalDocuments = envelopes.length;
const progressPercentage = (completedDocuments.length / totalDocuments) * 100;
@@ -41,9 +38,7 @@ export function MultiSignDocumentList({ envelopes, onDocumentSelect }: MultiSign
const allDocumentsCompleted = completedDocuments.length === totalDocuments;
const hasAssistantOrCcRecipient = envelopes.some(
(envelope) =>
envelope.recipient.role === RecipientRole.ASSISTANT ||
envelope.recipient.role === RecipientRole.CC,
(envelope) => envelope.recipient.role === RecipientRole.ASSISTANT || envelope.recipient.role === RecipientRole.CC,
);
function handleView(doc: DocumentEnvelope['document']) {
@@ -60,27 +55,25 @@ export function MultiSignDocumentList({ envelopes, onDocumentSelect }: MultiSign
return (
<div className="mx-auto mt-16 flex w-full max-w-lg flex-col md:mt-16 md:rounded-2xl md:border md:px-8 md:py-16 md:shadow-lg">
<div className="flex items-center justify-center">
<XCircle className="text-destructive h-16 w-16 md:h-24 md:w-24" strokeWidth={1.2} />
<XCircle className="h-16 w-16 text-destructive md:h-24 md:w-24" strokeWidth={1.2} />
</div>
<h2 className="mt-12 text-xl font-bold md:text-2xl">
<h2 className="mt-12 font-bold text-xl md:text-2xl">
<Trans>It looks like we ran into an issue!</Trans>
</h2>
<p className="text-muted-foreground mt-6">
<p className="mt-6 text-muted-foreground">
<Trans>
One of the documents in the current bundle has a signing role that is not compatible
with the current signing experience.
One of the documents in the current bundle has a signing role that is not compatible with the current
signing experience.
</Trans>
</p>
<p className="text-muted-foreground mt-2">
<Trans>
Assistants and Copy roles are currently not compatible with the multi-sign experience.
</Trans>
<p className="mt-2 text-muted-foreground">
<Trans>Assistants and Copy roles are currently not compatible with the multi-sign experience.</Trans>
</p>
<p className="text-muted-foreground mt-2">
<p className="mt-2 text-muted-foreground">
<Trans>Please contact the site owner for further assistance.</Trans>
</p>
</div>
@@ -88,22 +81,22 @@ export function MultiSignDocumentList({ envelopes, onDocumentSelect }: MultiSign
}
return (
<div className="bg-background mx-auto w-full max-w-lg md:my-12 md:rounded-2xl md:border md:p-8 md:shadow-lg">
<h2 className="text-foreground mb-1 text-lg font-semibold">
<div className="mx-auto w-full max-w-lg bg-background md:my-12 md:rounded-2xl md:border md:p-8 md:shadow-lg">
<h2 className="mb-1 font-semibold text-foreground text-lg">
<Trans>Sign Documents</Trans>
</h2>
<p className="text-muted-foreground text-sm">
<Trans>
You have been requested to sign the following documents. Review each document carefully
and complete the signing process.
You have been requested to sign the following documents. Review each document carefully and complete the
signing process.
</Trans>
</p>
{/* Progress Section */}
<div className="mt-6">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground font-medium">
<span className="font-medium text-muted-foreground">
<Trans>Progress</Trans>
</span>
<span className="text-muted-foreground">
@@ -118,13 +111,8 @@ export function MultiSignDocumentList({ envelopes, onDocumentSelect }: MultiSign
<div className="mt-6 flex flex-col gap-4">
{envelopes.map((envelope) => (
<div
key={envelope.document.id}
className="border-border flex items-center gap-4 rounded-lg border px-4 py-2"
>
<span className="text-foreground flex-1 truncate text-sm font-medium">
{envelope.document.title}
</span>
<div key={envelope.document.id} className="flex items-center gap-4 rounded-lg border border-border px-4 py-2">
<span className="flex-1 truncate font-medium text-foreground text-sm">{envelope.document.title}</span>
{match(envelope.recipient)
.with({ signingStatus: SigningStatus.SIGNED }, () => (
@@ -144,12 +132,7 @@ export function MultiSignDocumentList({ envelopes, onDocumentSelect }: MultiSign
))
.otherwise(() => null)}
<Button
className="-mr-2"
variant="outline"
size="sm"
onClick={() => handleView(envelope.document)}
>
<Button className="-mr-2" variant="outline" size="sm" onClick={() => handleView(envelope.document)}>
<EyeIcon className="mr-1 h-4 w-4" />
<Trans>View</Trans>
</Button>
@@ -1,14 +1,7 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, SigningStatus } from '@prisma/client';
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
@@ -22,9 +15,17 @@ import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, SigningStatus } from '@prisma/client';
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
import { useState } from 'react';
import { match, P } from 'ts-pattern';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
@@ -35,12 +36,7 @@ interface MultiSignDocumentSigningViewProps {
recipientId: number;
onBack: () => void;
onDocumentCompleted?: (data: { token: string; documentId: number; recipientId: number }) => void;
onDocumentRejected?: (data: {
token: string;
documentId: number;
recipientId: number;
reason: string;
}) => void;
onDocumentRejected?: (data: { token: string; documentId: number; recipientId: number; reason: string }) => void;
onDocumentError?: () => void;
onDocumentReady?: () => void;
isNameLocked?: boolean;
@@ -61,8 +57,7 @@ export const MultiSignDocumentSigningView = ({
const { _ } = useLingui();
const { toast } = useToast();
const { fullName, email, signature, setFullName, setSignature } =
useRequiredDocumentSigningContext();
const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
@@ -78,23 +73,19 @@ export const MultiSignDocumentSigningView = ({
);
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
const { mutateAsync: removeSignedFieldWithToken } =
trpc.field.removeSignedFieldWithToken.useMutation();
const { mutateAsync: removeSignedFieldWithToken } = trpc.field.removeSignedFieldWithToken.useMutation();
const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation();
const { mutateAsync: completeDocumentWithToken } = trpc.recipient.completeDocumentWithToken.useMutation();
const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type));
const [pendingFields, completedFields] = [
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
[],
document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ??
[],
sortFieldsByPosition(
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ?? [],
),
document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ?? [],
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
@@ -178,14 +169,14 @@ export const MultiSignDocumentSigningView = ({
};
return (
<div className="min-h-screen overflow-hidden bg-background">
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
<div className="min-h-screen bg-background">
<div className="relative h-full w-full p-8">
{match({ isLoading, document })
.with({ isLoading: true }, () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Trans>Loading document...</Trans>
</p>
</div>
@@ -193,7 +184,7 @@ export const MultiSignDocumentSigningView = ({
))
.with({ isLoading: false, document: undefined }, () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Trans>Failed to load document</Trans>
</p>
</div>
@@ -202,7 +193,7 @@ export const MultiSignDocumentSigningView = ({
<>
<div className="mx-auto flex w-full max-w-screen-xl items-baseline justify-between">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-semibold">{document.title}</h1>
<h1 className="font-semibold text-2xl">{document.title}</h1>
</div>
<Button variant="ghost" size="sm" onClick={onBack} className="p-2">
@@ -211,12 +202,8 @@ export const MultiSignDocumentSigningView = ({
</div>
{allowDocumentRejection && (
<div className="embed--Actions mb-4 mt-8 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningRejectDialog
documentId={document.id}
token={token}
onRejected={onRejected}
/>
<div className="embed--Actions mt-8 mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningRejectDialog documentId={document.id} token={token} onRejected={onRejected} />
</div>
)}
@@ -227,9 +214,15 @@ export const MultiSignDocumentSigningView = ({
})}
>
<PDFViewerLazy
envelopeItem={document.envelopeItems[0]}
token={token}
version="signed"
data={getDocumentDataUrlForPdfViewer({
envelopeId: document.envelopeId,
envelopeItemId: document.envelopeItems[0]?.id,
documentDataId: document.documentData.id,
version: 'current',
token,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => {
setHasDocumentLoaded(true);
onDocumentReady?.();
@@ -241,14 +234,14 @@ export const MultiSignDocumentSigningView = ({
{document.status !== DocumentStatus.COMPLETED && (
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-0 md:z-auto md:w-[350px] md:px-0"
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-0 md:bottom-[unset] md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="embed--DocumentWidget flex w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:py-6">
{/* Header */}
<div className="embed--DocumentWidgetHeader">
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-xl font-semibold text-foreground md:text-2xl">
<h3 className="font-semibold text-foreground text-xl md:text-2xl">
<Trans>Sign document</Trans>
</h3>
@@ -269,11 +262,11 @@ export const MultiSignDocumentSigningView = ({
</div>
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-2 text-muted-foreground text-sm">
<Trans>Sign the document to complete the process.</Trans>
</p>
<hr className="mb-8 mt-4 border-border" />
<hr className="mt-4 mb-8 border-border" />
</div>
{/* Form */}
@@ -301,13 +294,7 @@ export const MultiSignDocumentSigningView = ({
<Trans>Email</Trans>
</Label>
<Input
type="email"
id="email"
className="mt-2 bg-background"
value={email}
disabled
/>
<Input type="email" id="email" className="mt-2 bg-background" value={email} disabled />
</div>
{hasSignatureField && (
@@ -323,15 +310,9 @@ export const MultiSignDocumentSigningView = ({
fullName={fullName}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={
document.documentMeta?.typedSignatureEnabled
}
uploadSignatureEnabled={
document.documentMeta?.uploadSignatureEnabled
}
drawSignatureEnabled={
document.documentMeta?.drawSignatureEnabled
}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
/>
</div>
)}
@@ -348,11 +329,7 @@ export const MultiSignDocumentSigningView = ({
<Trans>Next</Trans>
</Button>
) : (
<Button
className="col-span-2"
loading={isSubmitting}
onClick={onDocumentComplete}
>
<Button className="col-span-2" loading={isSubmitting} onClick={onDocumentComplete}>
<Trans>Complete</Trans>
</Button>
)}
@@ -360,41 +337,30 @@ export const MultiSignDocumentSigningView = ({
</div>
</div>
)}
</div>
{hasDocumentLoaded && (
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip
key={pendingFields[0].id}
field={pendingFields[0]}
color="warning"
>
{hasDocumentLoaded && showPendingFieldTooltip && pendingFields.length > 0 && (
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}>
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
)}
</ElementVisible>
)}
{/* Fields */}
{hasDocumentLoaded && (
<EmbedDocumentFields
fields={pendingFields}
metadata={document.documentMeta}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
)}
{/* Fields */}
{hasDocumentLoaded && (
<EmbedDocumentFields
fields={pendingFields}
metadata={document.documentMeta}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
)}
{/* Completed fields */}
{document.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields
documentMeta={document.documentMeta ?? undefined}
fields={completedFields}
/>
)}
{/* Completed fields */}
{document.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields documentMeta={document.documentMeta ?? undefined} fields={completedFields} />
)}
</div>
</>
))
.otherwise(() => null)}