mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 08:42:12 +10:00
Compare commits
13 Commits
feat/envel
...
feat/expir
| Author | SHA1 | Date | |
|---|---|---|---|
| bdd4e5ae2e | |||
| f15c0778b5 | |||
| 0942a9234d | |||
| 53ee4342c7 | |||
| eb5be386ce | |||
| 06cb8b1f23 | |||
| 7f09ba72f4 | |||
| 40d7527036 | |||
| e9f4d0065e | |||
| b1102c8ba4 | |||
| d6bc4bd0ba | |||
| 262d9efdd5 | |||
| e24d00e23e |
@ -127,15 +127,15 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
|
|
||||||
const distributionMethod = watch('meta.distributionMethod');
|
const distributionMethod = watch('meta.distributionMethod');
|
||||||
|
|
||||||
const recipientsMissingSignatureFields = useMemo(
|
const everySignerHasSignature = useMemo(
|
||||||
() =>
|
() =>
|
||||||
envelope.recipients.filter(
|
envelope.recipients
|
||||||
(recipient) =>
|
.filter((recipient) => recipient.role === RecipientRole.SIGNER)
|
||||||
recipient.role === RecipientRole.SIGNER &&
|
.every((recipient) =>
|
||||||
!envelope.fields.some(
|
envelope.fields.some(
|
||||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
[envelope.recipients, envelope.fields],
|
[envelope.recipients, envelope.fields],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -178,7 +178,7 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
<Trans>Recipients will be able to sign the document once sent</Trans>
|
<Trans>Recipients will be able to sign the document once sent</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{recipientsMissingSignatureFields.length === 0 ? (
|
{everySignerHasSignature ? (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
<fieldset disabled={isSubmitting}>
|
<fieldset disabled={isSubmitting}>
|
||||||
@ -350,8 +350,6 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="text-muted-foreground divide-y">
|
<ul className="text-muted-foreground divide-y">
|
||||||
{/* Todo: Envelopes - I don't think this section shows up */}
|
|
||||||
|
|
||||||
{recipients.length === 0 && (
|
{recipients.length === 0 && (
|
||||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||||
<Trans>No recipients</Trans>
|
<Trans>No recipients</Trans>
|
||||||
@ -429,13 +427,10 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
<>
|
<>
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<Trans>The following signers are missing signature fields:</Trans>
|
<Trans>
|
||||||
|
Some signers have not been assigned a signature field. Please assign at least 1
|
||||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
signature field to each signer before proceeding.
|
||||||
{recipientsMissingSignatureFields.map((recipient) => (
|
</Trans>
|
||||||
<li key={recipient.id}>{recipient.email}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,40 @@
|
|||||||
import { useLingui } from '@lingui/react/macro';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { createCallable } from 'react-call';
|
import { createCallable } from 'react-call';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
Dialog,
|
||||||
CommandEmpty,
|
DialogContent,
|
||||||
CommandGroup,
|
DialogDescription,
|
||||||
CommandInput,
|
DialogFooter,
|
||||||
CommandItem,
|
DialogHeader,
|
||||||
CommandList,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/command';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
|
||||||
|
const ZSignFieldDropdownFormSchema = z.object({
|
||||||
|
dropdown: z.string().min(1, { message: msg`Option is required`.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TSignFieldDropdownFormSchema = z.infer<typeof ZSignFieldDropdownFormSchema>;
|
||||||
|
|
||||||
export type SignFieldDropdownDialogProps = {
|
export type SignFieldDropdownDialogProps = {
|
||||||
fieldMeta: TDropdownFieldMeta;
|
fieldMeta: TDropdownFieldMeta;
|
||||||
@ -21,20 +46,72 @@ export const SignFieldDropdownDialog = createCallable<SignFieldDropdownDialogPro
|
|||||||
|
|
||||||
const values = fieldMeta.values?.map((value) => value.value) ?? [];
|
const values = fieldMeta.values?.map((value) => value.value) ?? [];
|
||||||
|
|
||||||
|
const form = useForm<TSignFieldDropdownFormSchema>({
|
||||||
|
resolver: zodResolver(ZSignFieldDropdownFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
dropdown: fieldMeta.defaultValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
<CommandInput placeholder={t`Select an option`} />
|
<DialogContent position="center">
|
||||||
<CommandList>
|
<DialogHeader>
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
<DialogTitle>
|
||||||
<CommandGroup heading={t`Options`}>
|
<Trans>Sign Dropdown Field</Trans>
|
||||||
{values.map((value, i) => (
|
</DialogTitle>
|
||||||
<CommandItem onSelect={() => call.end(value)} key={i} value={value}>
|
|
||||||
{value}
|
<DialogDescription className="mt-4">
|
||||||
</CommandItem>
|
<Trans>Select a value to sign into the field</Trans>
|
||||||
))}
|
</DialogDescription>
|
||||||
</CommandGroup>
|
</DialogHeader>
|
||||||
</CommandList>
|
|
||||||
</CommandDialog>
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((data) => call.end(data.dropdown))}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dropdown"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="bg-background">
|
||||||
|
<SelectValue placeholder={t`Select an option`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{values.map((value, i) => (
|
||||||
|
<SelectItem key={i} value={value}>
|
||||||
|
{value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit">
|
||||||
|
<Trans>Sign</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -45,32 +45,26 @@ const ZDropdownFieldFormSchema = z
|
|||||||
.min(1, {
|
.min(1, {
|
||||||
message: msg`Dropdown must have at least one option`.id,
|
message: msg`Dropdown must have at least one option`.id,
|
||||||
})
|
})
|
||||||
.superRefine((values, ctx) => {
|
.refine(
|
||||||
const seen = new Map<string, number[]>(); // value → indices
|
(data) => {
|
||||||
values.forEach((item, index) => {
|
// Todo: Envelopes - This doesn't work.
|
||||||
const key = item.value;
|
console.log({
|
||||||
if (!seen.has(key)) {
|
data,
|
||||||
seen.set(key, []);
|
});
|
||||||
}
|
|
||||||
seen.get(key)!.push(index);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const [key, indices] of seen) {
|
if (data) {
|
||||||
if (indices.length > 1 && key.trim() !== '') {
|
const values = data.map((item) => item.value);
|
||||||
for (const i of indices) {
|
return new Set(values).size === values.length;
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: msg`Duplicate values are not allowed`.id,
|
|
||||||
path: [i, 'value'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
return true;
|
||||||
}),
|
},
|
||||||
|
{
|
||||||
|
message: 'Duplicate values are not allowed',
|
||||||
|
},
|
||||||
|
),
|
||||||
required: z.boolean().optional(),
|
required: z.boolean().optional(),
|
||||||
readOnly: z.boolean().optional(),
|
readOnly: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
// Todo: Envelopes - This doesn't work
|
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
// Default value must be one of the available options
|
// Default value must be one of the available options
|
||||||
@ -117,20 +111,7 @@ export const EditorFieldDropdownForm = ({
|
|||||||
|
|
||||||
const addValue = () => {
|
const addValue = () => {
|
||||||
const currentValues = form.getValues('values') || [];
|
const currentValues = form.getValues('values') || [];
|
||||||
|
const newValues = [...currentValues, { value: 'New option' }];
|
||||||
let newValue = 'New option';
|
|
||||||
|
|
||||||
// Iterate to create a unique value
|
|
||||||
for (let i = 0; i < currentValues.length; i++) {
|
|
||||||
newValue = `New option ${i + 1}`;
|
|
||||||
if (currentValues.some((item) => item.value === `New option ${i + 1}`)) {
|
|
||||||
newValue = `New option ${i + 1}`;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newValues = [...currentValues, { value: newValue }];
|
|
||||||
|
|
||||||
form.setValue('values', newValues);
|
form.setValue('values', newValues);
|
||||||
};
|
};
|
||||||
@ -146,10 +127,6 @@ export const EditorFieldDropdownForm = ({
|
|||||||
newValues.splice(index, 1);
|
newValues.splice(index, 1);
|
||||||
|
|
||||||
form.setValue('values', newValues);
|
form.setValue('values', newValues);
|
||||||
|
|
||||||
if (form.getValues('defaultValue') === newValues[index].value) {
|
|
||||||
form.setValue('defaultValue', undefined);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -186,26 +163,20 @@ export const EditorFieldDropdownForm = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
|
// Todo: Envelopes - This is buggy, removing/adding should update the default value.
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value === null ? '-1' : field.value}
|
value={field.value}
|
||||||
onValueChange={(value) => field.onChange(value === undefined ? null : value)}
|
onValueChange={(val) => field.onChange(val)}
|
||||||
>
|
>
|
||||||
{/* Todo: Envelopes - THis is cooked */}
|
|
||||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||||
<SelectValue placeholder={t`Default Value`} />
|
<SelectValue placeholder={t`Default Value`} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper">
|
<SelectContent position="popper">
|
||||||
{(formValues.values || [])
|
{(formValues.values || []).map((item, index) => (
|
||||||
.filter((item) => item.value)
|
<SelectItem key={index} value={item.value || ''}>
|
||||||
.map((item, index) => (
|
{item.value}
|
||||||
<SelectItem key={index} value={item.value || ''}>
|
</SelectItem>
|
||||||
{item.value}
|
))}
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<SelectItem value={'-1'}>
|
|
||||||
<Trans>None</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@ -1,32 +1,15 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { PlusIcon, Trash } from 'lucide-react';
|
import { PlusIcon, Trash } from 'lucide-react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import type { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
type TRadioFieldMeta as RadioFieldMeta,
|
|
||||||
ZRadioFieldMeta,
|
|
||||||
} from '@documenso/lib/types/field-meta';
|
|
||||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
import {
|
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -34,26 +17,31 @@ import {
|
|||||||
EditorGenericRequiredField,
|
EditorGenericRequiredField,
|
||||||
} from './editor-field-generic-field-forms';
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
const ZRadioFieldFormSchema = ZRadioFieldMeta.pick({
|
const ZRadioFieldFormSchema = z
|
||||||
label: true,
|
.object({
|
||||||
direction: true,
|
label: z.string().optional(),
|
||||||
values: true,
|
values: z
|
||||||
required: true,
|
.object({ id: z.number(), checked: z.boolean(), value: z.string() })
|
||||||
readOnly: true,
|
.array()
|
||||||
}).refine(
|
.min(1)
|
||||||
(data) => {
|
.optional(),
|
||||||
// There cannot be more than one checked option
|
required: z.boolean().optional(),
|
||||||
if (data.values) {
|
readOnly: z.boolean().optional(),
|
||||||
const checkedValues = data.values.filter((option) => option.checked);
|
})
|
||||||
return checkedValues.length <= 1;
|
.refine(
|
||||||
}
|
(data) => {
|
||||||
return true;
|
// There cannot be more than one checked option
|
||||||
},
|
if (data.values) {
|
||||||
{
|
const checkedValues = data.values.filter((option) => option.checked);
|
||||||
message: 'There cannot be more than one checked option',
|
return checkedValues.length <= 1;
|
||||||
path: ['values'],
|
}
|
||||||
},
|
return true;
|
||||||
);
|
},
|
||||||
|
{
|
||||||
|
message: 'There cannot be more than one checked option',
|
||||||
|
path: ['values'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
|
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
|
||||||
|
|
||||||
@ -65,12 +53,9 @@ export type EditorFieldRadioFormProps = {
|
|||||||
export const EditorFieldRadioForm = ({
|
export const EditorFieldRadioForm = ({
|
||||||
value = {
|
value = {
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
direction: 'vertical',
|
|
||||||
},
|
},
|
||||||
onValueChange,
|
onValueChange,
|
||||||
}: EditorFieldRadioFormProps) => {
|
}: EditorFieldRadioFormProps) => {
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
const form = useForm<TRadioFieldFormSchema>({
|
const form = useForm<TRadioFieldFormSchema>({
|
||||||
resolver: zodResolver(ZRadioFieldFormSchema),
|
resolver: zodResolver(ZRadioFieldFormSchema),
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
@ -79,7 +64,6 @@ export const EditorFieldRadioForm = ({
|
|||||||
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
|
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
|
||||||
required: value.required || false,
|
required: value.required || false,
|
||||||
readOnly: value.readOnly || false,
|
readOnly: value.readOnly || false,
|
||||||
direction: value.direction || 'vertical',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -123,35 +107,7 @@ export const EditorFieldRadioForm = ({
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form>
|
<form>
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2 pb-2">
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="direction"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Direction</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
|
||||||
<SelectValue placeholder={t`Select direction`} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent position="popper">
|
|
||||||
<SelectItem value="vertical">
|
|
||||||
<Trans>Vertical</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="horizontal">
|
|
||||||
<Trans>Horizontal</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditorGenericRequiredField formControl={form.control} />
|
<EditorGenericRequiredField formControl={form.control} />
|
||||||
|
|
||||||
<EditorGenericReadOnlyField formControl={form.control} />
|
<EditorGenericReadOnlyField formControl={form.control} />
|
||||||
|
|||||||
@ -223,6 +223,8 @@ export const DocumentEditForm = ({
|
|||||||
meta: {
|
meta: {
|
||||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||||
signingOrder: data.signingOrder,
|
signingOrder: data.signingOrder,
|
||||||
|
expiryAmount: data.meta.expiryAmount,
|
||||||
|
expiryUnit: data.meta.expiryUnit,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@ -247,6 +249,8 @@ export const DocumentEditForm = ({
|
|||||||
meta: {
|
meta: {
|
||||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||||
signingOrder: data.signingOrder,
|
signingOrder: data.signingOrder,
|
||||||
|
expiryAmount: data.meta.expiryAmount,
|
||||||
|
expiryUnit: data.meta.expiryUnit,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@ -476,6 +480,17 @@ export const DocumentEditForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
signingOrder={document.documentMeta?.signingOrder}
|
signingOrder={document.documentMeta?.signingOrder}
|
||||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
|
expiryAmount={document.documentMeta?.expiryAmount}
|
||||||
|
expiryUnit={
|
||||||
|
document.documentMeta?.expiryUnit as
|
||||||
|
| 'minutes'
|
||||||
|
| 'hours'
|
||||||
|
| 'days'
|
||||||
|
| 'weeks'
|
||||||
|
| 'months'
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
onAutoSave={onAddSignersFormAutoSave}
|
onAutoSave={onAddSignersFormAutoSave}
|
||||||
|
|||||||
@ -156,6 +156,14 @@ export const DocumentPageViewRecipients = ({
|
|||||||
</PopoverHover>
|
</PopoverHover>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{envelope.status !== DocumentStatus.DRAFT &&
|
||||||
|
recipient.signingStatus === SigningStatus.EXPIRED && (
|
||||||
|
<Badge variant="warning">
|
||||||
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
|
<Trans>Expired</Trans>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
{envelope.status === DocumentStatus.PENDING &&
|
{envelope.status === DocumentStatus.PENDING &&
|
||||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||||
recipient.role !== RecipientRole.CC && (
|
recipient.role !== RecipientRole.CC && (
|
||||||
|
|||||||
@ -96,7 +96,7 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
selectedRecipientId,
|
selectedRecipientId,
|
||||||
selectedEnvelopeItemId,
|
selectedEnvelopeItemId,
|
||||||
}: EnvelopeEditorFieldDragDropProps) => {
|
}: EnvelopeEditorFieldDragDropProps) => {
|
||||||
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, isTemplate } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
@ -262,10 +262,6 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
};
|
};
|
||||||
}, [onMouseClick, onMouseMove, selectedField]);
|
}, [onMouseClick, onMouseMove, selectedField]);
|
||||||
|
|
||||||
const selectedRecipientColor = useMemo(() => {
|
|
||||||
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
|
|
||||||
}, [selectedRecipientId, getRecipientColorKey]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
|
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
|
||||||
@ -277,23 +273,12 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
onClick={() => setSelectedField(field.type)}
|
onClick={() => setSelectedField(field.type)}
|
||||||
onMouseDown={() => setSelectedField(field.type)}
|
onMouseDown={() => setSelectedField(field.type)}
|
||||||
data-selected={selectedField === field.type ? true : undefined}
|
data-selected={selectedField === field.type ? true : undefined}
|
||||||
className={cn(
|
className="group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-gray-200 px-4 transition-colors hover:border-blue-300 hover:bg-blue-50"
|
||||||
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-gray-200 px-4 transition-colors',
|
|
||||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||||
field.className,
|
field.className,
|
||||||
{
|
|
||||||
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
|
|
||||||
'group-hover:text-recipient-blue': selectedRecipientColor === 'blue',
|
|
||||||
'group-hover:text-recipient-purple': selectedRecipientColor === 'purple',
|
|
||||||
'group-hover:text-recipient-orange': selectedRecipientColor === 'orange',
|
|
||||||
'group-hover:text-recipient-yellow': selectedRecipientColor === 'yellow',
|
|
||||||
'group-hover:text-recipient-pink': selectedRecipientColor === 'pink',
|
|
||||||
},
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
|
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
|
||||||
@ -307,7 +292,8 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
|
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
|
||||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
|
// selectedSignerStyles?.base,
|
||||||
|
RECIPIENT_COLOR_STYLES.yellow.base, // Todo: Envelopes
|
||||||
{
|
{
|
||||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||||
'dark:text-black/60': isFieldWithinBounds,
|
'dark:text-black/60': isFieldWithinBounds,
|
||||||
|
|||||||
@ -3,12 +3,15 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import type { FieldType } from '@prisma/client';
|
import type { FieldType } from '@prisma/client';
|
||||||
import Konva from 'konva';
|
import Konva from 'konva';
|
||||||
|
import type { Layer } from 'konva/lib/Layer';
|
||||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||||
import type { Transformer } from 'konva/lib/shapes/Transformer';
|
import type { Transformer } from 'konva/lib/shapes/Transformer';
|
||||||
import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react';
|
import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react';
|
||||||
|
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
import { usePageContext } from 'react-pdf';
|
||||||
|
|
||||||
|
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||||
@ -23,10 +26,27 @@ import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
|||||||
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||||
|
|
||||||
export default function EnvelopeEditorFieldsPageRenderer() {
|
export default function EnvelopeEditorFieldsPageRenderer() {
|
||||||
|
const pageContext = usePageContext();
|
||||||
|
|
||||||
|
if (!pageContext) {
|
||||||
|
throw new Error('Unable to find Page context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { _className, page, rotate, scale } = pageContext;
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||||
|
}
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||||
|
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const stage = useRef<Konva.Stage | null>(null);
|
||||||
|
const pageLayer = useRef<Layer | null>(null);
|
||||||
const interactiveTransformer = useRef<Transformer | null>(null);
|
const interactiveTransformer = useRef<Transformer | null>(null);
|
||||||
|
|
||||||
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
|
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
|
||||||
@ -34,17 +54,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
||||||
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
||||||
|
|
||||||
const {
|
const viewport = useMemo(
|
||||||
stage,
|
() => page.getViewport({ scale, rotation: rotate }),
|
||||||
pageLayer,
|
[page, rotate, scale],
|
||||||
canvasElement,
|
);
|
||||||
konvaContainer,
|
|
||||||
pageContext,
|
|
||||||
scaledViewport,
|
|
||||||
unscaledViewport,
|
|
||||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
|
||||||
|
|
||||||
const { _className, scale } = pageContext;
|
|
||||||
|
|
||||||
const localPageFields = useMemo(
|
const localPageFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -55,6 +68,44 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
[editorFields.localFields, pageContext.pageNumber],
|
[editorFields.localFields, pageContext.pageNumber],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Custom renderer from Konva examples.
|
||||||
|
useEffect(
|
||||||
|
function drawPageOnCanvas() {
|
||||||
|
if (!page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current: canvas } = canvasElement;
|
||||||
|
const { current: container } = konvaContainer;
|
||||||
|
|
||||||
|
if (!canvas || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContext: RenderParameters = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||||
|
viewport,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancellable = page.render(renderContext);
|
||||||
|
const runningTask = cancellable;
|
||||||
|
|
||||||
|
cancellable.promise.catch(() => {
|
||||||
|
// Intentionally empty
|
||||||
|
});
|
||||||
|
|
||||||
|
void cancellable.promise.then(() => {
|
||||||
|
createPageCanvas(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
runningTask.cancel();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[page, viewport],
|
||||||
|
);
|
||||||
|
|
||||||
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||||
console.log('Field resized or moved');
|
console.log('Field resized or moved');
|
||||||
|
|
||||||
@ -69,7 +120,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
const fieldGroup = event.target as Konva.Group;
|
const fieldGroup = event.target as Konva.Group;
|
||||||
const fieldFormId = fieldGroup.id();
|
const fieldFormId = fieldGroup.id();
|
||||||
|
|
||||||
// Note: This values are scaled.
|
|
||||||
const {
|
const {
|
||||||
width: fieldPixelWidth,
|
width: fieldPixelWidth,
|
||||||
height: fieldPixelHeight,
|
height: fieldPixelHeight,
|
||||||
@ -80,8 +130,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
skipShadow: true,
|
skipShadow: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageHeight = scaledViewport.height;
|
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(container);
|
||||||
const pageWidth = scaledViewport.width;
|
|
||||||
|
|
||||||
// Calculate x and y as a percentage of the page width and height
|
// Calculate x and y as a percentage of the page width and height
|
||||||
const positionPercentX = (fieldX / pageWidth) * 100;
|
const positionPercentX = (fieldX / pageWidth) * 100;
|
||||||
@ -116,7 +165,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderFieldOnLayer = (field: TLocalField) => {
|
const renderFieldOnLayer = (field: TLocalField) => {
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current || !interactiveTransformer.current) {
|
||||||
console.error('Layer not loaded yet');
|
console.error('Layer not loaded yet');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -125,8 +174,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
const isFieldEditable =
|
const isFieldEditable =
|
||||||
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
|
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
|
||||||
|
|
||||||
const { fieldGroup } = renderField({
|
const { fieldGroup, isFirstRender } = renderField({
|
||||||
scale,
|
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
field: {
|
field: {
|
||||||
renderId: field.formId,
|
renderId: field.formId,
|
||||||
@ -135,8 +183,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
inserted: false,
|
inserted: false,
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
},
|
},
|
||||||
pageWidth: unscaledViewport.width,
|
pageWidth: viewport.width,
|
||||||
pageHeight: unscaledViewport.height,
|
pageHeight: viewport.height,
|
||||||
color: getRecipientColorKey(field.recipientId),
|
color: getRecipientColorKey(field.recipientId),
|
||||||
editable: isFieldEditable,
|
editable: isFieldEditable,
|
||||||
mode: 'edit',
|
mode: 'edit',
|
||||||
@ -162,14 +210,24 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Konva page canvas and all fields and interactions.
|
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||||
*/
|
*/
|
||||||
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
const createPageCanvas = (container: HTMLDivElement) => {
|
||||||
|
stage.current = new Konva.Stage({
|
||||||
|
container,
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the main layer for interactive elements.
|
||||||
|
pageLayer.current = new Konva.Layer();
|
||||||
|
stage.current?.add(pageLayer.current);
|
||||||
|
|
||||||
// Initialize snap guides layer
|
// Initialize snap guides layer
|
||||||
// snapGuideLayer.current = initializeSnapGuides(stage.current);
|
// snapGuideLayer.current = initializeSnapGuides(stage.current);
|
||||||
|
|
||||||
// Add transformer for resizing and rotating.
|
// Add transformer for resizing and rotating.
|
||||||
interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer);
|
interactiveTransformer.current = createInteractiveTransformer(stage.current, pageLayer.current);
|
||||||
|
|
||||||
// Render the fields.
|
// Render the fields.
|
||||||
for (const field of localPageFields) {
|
for (const field of localPageFields) {
|
||||||
@ -177,12 +235,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle stage click to deselect.
|
// Handle stage click to deselect.
|
||||||
currentStage.on('click', (e) => {
|
stage.current?.on('click', (e) => {
|
||||||
removePendingField();
|
removePendingField();
|
||||||
|
|
||||||
if (e.target === stage.current) {
|
if (e.target === stage.current) {
|
||||||
setSelectedFields([]);
|
setSelectedFields([]);
|
||||||
currentPageLayer.batchDraw();
|
pageLayer.current?.batchDraw();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -209,12 +267,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
setSelectedFields([e.target]);
|
setSelectedFields([e.target]);
|
||||||
};
|
};
|
||||||
|
|
||||||
currentStage.on('dragstart', onDragStartOrEnd);
|
stage.current?.on('dragstart', onDragStartOrEnd);
|
||||||
currentStage.on('dragend', onDragStartOrEnd);
|
stage.current?.on('dragend', onDragStartOrEnd);
|
||||||
currentStage.on('transformstart', () => setIsFieldChanging(true));
|
stage.current?.on('transformstart', () => setIsFieldChanging(true));
|
||||||
currentStage.on('transformend', () => setIsFieldChanging(false));
|
stage.current?.on('transformend', () => setIsFieldChanging(false));
|
||||||
|
|
||||||
currentPageLayer.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -226,10 +284,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
* - Selecting multiple fields
|
* - Selecting multiple fields
|
||||||
* - Selecting empty area to create fields
|
* - Selecting empty area to create fields
|
||||||
*/
|
*/
|
||||||
const createInteractiveTransformer = (
|
const createInteractiveTransformer = (stage: Konva.Stage, layer: Konva.Layer) => {
|
||||||
currentStage: Konva.Stage,
|
|
||||||
currentPageLayer: Konva.Layer,
|
|
||||||
) => {
|
|
||||||
const transformer = new Konva.Transformer({
|
const transformer = new Konva.Transformer({
|
||||||
rotateEnabled: false,
|
rotateEnabled: false,
|
||||||
keepRatio: false,
|
keepRatio: false,
|
||||||
@ -246,39 +301,36 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
currentPageLayer.add(transformer);
|
layer.add(transformer);
|
||||||
|
|
||||||
// Add selection rectangle.
|
// Add selection rectangle.
|
||||||
const selectionRectangle = new Konva.Rect({
|
const selectionRectangle = new Konva.Rect({
|
||||||
fill: 'rgba(24, 160, 251, 0.3)',
|
fill: 'rgba(24, 160, 251, 0.3)',
|
||||||
visible: false,
|
visible: false,
|
||||||
});
|
});
|
||||||
currentPageLayer.add(selectionRectangle);
|
layer.add(selectionRectangle);
|
||||||
|
|
||||||
let x1: number;
|
let x1: number;
|
||||||
let y1: number;
|
let y1: number;
|
||||||
let x2: number;
|
let x2: number;
|
||||||
let y2: number;
|
let y2: number;
|
||||||
|
|
||||||
currentStage.on('mousedown touchstart', (e) => {
|
stage.on('mousedown touchstart', (e) => {
|
||||||
// do nothing if we mousedown on any shape
|
// do nothing if we mousedown on any shape
|
||||||
if (e.target !== currentStage) {
|
if (e.target !== stage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointerPosition = currentStage.getPointerPosition();
|
const pointerPosition = stage.getPointerPosition();
|
||||||
|
|
||||||
if (!pointerPosition) {
|
if (!pointerPosition) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`pointerPosition.x: ${pointerPosition.x}`);
|
x1 = pointerPosition.x;
|
||||||
console.log(`pointerPosition.y: ${pointerPosition.y}`);
|
y1 = pointerPosition.y;
|
||||||
|
x2 = pointerPosition.x;
|
||||||
x1 = pointerPosition.x / scale;
|
y2 = pointerPosition.y;
|
||||||
y1 = pointerPosition.y / scale;
|
|
||||||
x2 = pointerPosition.x / scale;
|
|
||||||
y2 = pointerPosition.y / scale;
|
|
||||||
|
|
||||||
selectionRectangle.setAttrs({
|
selectionRectangle.setAttrs({
|
||||||
x: x1,
|
x: x1,
|
||||||
@ -289,7 +341,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
currentStage.on('mousemove touchmove', () => {
|
stage.on('mousemove touchmove', () => {
|
||||||
// do nothing if we didn't start selection
|
// do nothing if we didn't start selection
|
||||||
if (!selectionRectangle.visible()) {
|
if (!selectionRectangle.visible()) {
|
||||||
return;
|
return;
|
||||||
@ -297,14 +349,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
|
|
||||||
selectionRectangle.moveToTop();
|
selectionRectangle.moveToTop();
|
||||||
|
|
||||||
const pointerPosition = currentStage.getPointerPosition();
|
const pointerPosition = stage.getPointerPosition();
|
||||||
|
|
||||||
if (!pointerPosition) {
|
if (!pointerPosition) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
x2 = pointerPosition.x / scale;
|
x2 = pointerPosition.x;
|
||||||
y2 = pointerPosition.y / scale;
|
y2 = pointerPosition.y;
|
||||||
|
|
||||||
selectionRectangle.setAttrs({
|
selectionRectangle.setAttrs({
|
||||||
x: Math.min(x1, x2),
|
x: Math.min(x1, x2),
|
||||||
@ -314,7 +366,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
currentStage.on('mouseup touchend', () => {
|
stage.on('mouseup touchend', () => {
|
||||||
// do nothing if we didn't start selection
|
// do nothing if we didn't start selection
|
||||||
if (!selectionRectangle.visible()) {
|
if (!selectionRectangle.visible()) {
|
||||||
return;
|
return;
|
||||||
@ -325,41 +377,38 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
selectionRectangle.visible(false);
|
selectionRectangle.visible(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
const stageFieldGroups = currentStage.find('.field-group') || [];
|
const stageFieldGroups = stage.find('.field-group') || [];
|
||||||
const box = selectionRectangle.getClientRect();
|
const box = selectionRectangle.getClientRect();
|
||||||
const selectedFieldGroups = stageFieldGroups.filter(
|
const selectedFieldGroups = stageFieldGroups.filter(
|
||||||
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
|
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
|
||||||
);
|
);
|
||||||
setSelectedFields(selectedFieldGroups);
|
setSelectedFields(selectedFieldGroups);
|
||||||
|
|
||||||
const unscaledBoxWidth = box.width / scale;
|
|
||||||
const unscaledBoxHeight = box.height / scale;
|
|
||||||
|
|
||||||
// Create a field if no items are selected or the size is too small.
|
// Create a field if no items are selected or the size is too small.
|
||||||
if (
|
if (
|
||||||
selectedFieldGroups.length === 0 &&
|
selectedFieldGroups.length === 0 &&
|
||||||
canvasElement.current &&
|
canvasElement.current &&
|
||||||
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
|
box.width > MIN_FIELD_WIDTH_PX &&
|
||||||
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
|
box.height > MIN_FIELD_HEIGHT_PX &&
|
||||||
editorFields.selectedRecipient &&
|
editorFields.selectedRecipient &&
|
||||||
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
|
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
|
||||||
) {
|
) {
|
||||||
const pendingFieldCreation = new Konva.Rect({
|
const pendingFieldCreation = new Konva.Rect({
|
||||||
name: 'pending-field-creation',
|
name: 'pending-field-creation',
|
||||||
x: box.x / scale,
|
x: box.x,
|
||||||
y: box.y / scale,
|
y: box.y,
|
||||||
width: unscaledBoxWidth,
|
width: box.width,
|
||||||
height: unscaledBoxHeight,
|
height: box.height,
|
||||||
fill: 'rgba(24, 160, 251, 0.3)',
|
fill: 'rgba(24, 160, 251, 0.3)',
|
||||||
});
|
});
|
||||||
|
|
||||||
currentPageLayer.add(pendingFieldCreation);
|
layer.add(pendingFieldCreation);
|
||||||
setPendingFieldCreation(pendingFieldCreation);
|
setPendingFieldCreation(pendingFieldCreation);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clicks should select/deselect shapes
|
// Clicks should select/deselect shapes
|
||||||
currentStage.on('click tap', function (e) {
|
stage.on('click tap', function (e) {
|
||||||
// if we are selecting with rect, do nothing
|
// if we are selecting with rect, do nothing
|
||||||
if (
|
if (
|
||||||
selectionRectangle.visible() &&
|
selectionRectangle.visible() &&
|
||||||
@ -370,7 +419,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If empty area clicked, remove all selections
|
// If empty area clicked, remove all selections
|
||||||
if (e.target === stage.current) {
|
if (e.target === stage) {
|
||||||
setSelectedFields([]);
|
setSelectedFields([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -506,13 +555,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(canvasElement.current);
|
||||||
|
|
||||||
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
|
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
|
||||||
width: pixelWidth,
|
width: pixelWidth,
|
||||||
height: pixelHeight,
|
height: pixelHeight,
|
||||||
positionX: pixelX,
|
positionX: pixelX,
|
||||||
positionY: pixelY,
|
positionY: pixelY,
|
||||||
pageWidth: unscaledViewport.width,
|
pageWidth,
|
||||||
pageHeight: unscaledViewport.height,
|
pageHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
editorFields.addField({
|
editorFields.addField({
|
||||||
@ -546,10 +597,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||||
className="relative w-full"
|
|
||||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
|
||||||
>
|
|
||||||
{selectedKonvaFieldGroups.length > 0 &&
|
{selectedKonvaFieldGroups.length > 0 &&
|
||||||
interactiveTransformer.current &&
|
interactiveTransformer.current &&
|
||||||
!isFieldChanging && (
|
!isFieldChanging && (
|
||||||
@ -606,15 +654,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top:
|
top: pendingFieldCreation.y() + pendingFieldCreation.getClientRect().height + 5 + 'px',
|
||||||
pendingFieldCreation.y() * scale +
|
left: pendingFieldCreation.x() + pendingFieldCreation.getClientRect().width / 2 + 'px',
|
||||||
pendingFieldCreation.getClientRect().height +
|
|
||||||
5 +
|
|
||||||
'px',
|
|
||||||
left:
|
|
||||||
pendingFieldCreation.x() * scale +
|
|
||||||
pendingFieldCreation.getClientRect().width / 2 +
|
|
||||||
'px',
|
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
}}
|
}}
|
||||||
@ -632,15 +673,13 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* The element Konva will inject it's canvas into. */}
|
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
||||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
|
||||||
|
|
||||||
{/* Canvas the PDF will be rendered on. */}
|
|
||||||
<canvas
|
<canvas
|
||||||
className={`${_className}__canvas z-0`}
|
className={`${_className}__canvas z-0`}
|
||||||
|
height={viewport.height}
|
||||||
ref={canvasElement}
|
ref={canvasElement}
|
||||||
height={scaledViewport.height}
|
width={viewport.width}
|
||||||
width={scaledViewport.width}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -60,7 +60,7 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
|||||||
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
|
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EnvelopeEditorFieldsPage = () => {
|
export const EnvelopeEditorPageFields = () => {
|
||||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
@ -109,7 +109,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||||
|
|
||||||
{/* Document View */}
|
{/* Document View */}
|
||||||
<div className="mt-4 flex justify-center p-4">
|
<div className="mt-4 flex justify-center">
|
||||||
{currentEnvelopeItem !== null ? (
|
{currentEnvelopeItem !== null ? (
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
|
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
|
||||||
) : (
|
) : (
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import Konva from 'konva';
|
||||||
|
import type { Layer } from 'konva/lib/Layer';
|
||||||
|
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
import { usePageContext } from 'react-pdf';
|
||||||
|
|
||||||
|
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||||
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||||
|
|
||||||
|
export default function EnvelopeEditorPagePreviewRenderer() {
|
||||||
|
const pageContext = usePageContext();
|
||||||
|
|
||||||
|
if (!pageContext) {
|
||||||
|
throw new Error('Unable to find Page context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { _className, page, rotate, scale } = pageContext;
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||||
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||||
|
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const stage = useRef<Konva.Stage | null>(null);
|
||||||
|
const pageLayer = useRef<Layer | null>(null);
|
||||||
|
|
||||||
|
const viewport = useMemo(
|
||||||
|
() => page.getViewport({ scale, rotation: rotate }),
|
||||||
|
[page, rotate, scale],
|
||||||
|
);
|
||||||
|
|
||||||
|
const localPageFields = useMemo(
|
||||||
|
() =>
|
||||||
|
editorFields.localFields.filter(
|
||||||
|
(field) =>
|
||||||
|
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||||
|
),
|
||||||
|
[editorFields.localFields, pageContext.pageNumber],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom renderer from Konva examples.
|
||||||
|
useEffect(
|
||||||
|
function drawPageOnCanvas() {
|
||||||
|
if (!page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current: canvas } = canvasElement;
|
||||||
|
const { current: container } = konvaContainer;
|
||||||
|
|
||||||
|
if (!canvas || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContext: RenderParameters = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||||
|
viewport,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancellable = page.render(renderContext);
|
||||||
|
const runningTask = cancellable;
|
||||||
|
|
||||||
|
cancellable.promise.catch(() => {
|
||||||
|
// Intentionally empty
|
||||||
|
});
|
||||||
|
|
||||||
|
void cancellable.promise.then(() => {
|
||||||
|
createPageCanvas(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
runningTask.cancel();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[page, viewport],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFieldOnLayer = (field: TLocalField) => {
|
||||||
|
if (!pageLayer.current) {
|
||||||
|
console.error('Layer not loaded yet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderField({
|
||||||
|
pageLayer: pageLayer.current,
|
||||||
|
field: {
|
||||||
|
renderId: field.formId,
|
||||||
|
...field,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
|
},
|
||||||
|
pageWidth: viewport.width,
|
||||||
|
pageHeight: viewport.height,
|
||||||
|
color: getRecipientColorKey(field.recipientId),
|
||||||
|
editable: false,
|
||||||
|
mode: 'export',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||||
|
*/
|
||||||
|
const createPageCanvas = (container: HTMLDivElement) => {
|
||||||
|
stage.current = new Konva.Stage({
|
||||||
|
container,
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the main layer for interactive elements.
|
||||||
|
pageLayer.current = new Konva.Layer();
|
||||||
|
stage.current?.add(pageLayer.current);
|
||||||
|
|
||||||
|
// Render the fields.
|
||||||
|
for (const field of localPageFields) {
|
||||||
|
renderFieldOnLayer(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
pageLayer.current.batchDraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render fields when they are added or removed from the localFields.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pageLayer.current || !stage.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If doesn't exist in localFields, destroy it since it's been deleted.
|
||||||
|
pageLayer.current.find('Group').forEach((group) => {
|
||||||
|
if (
|
||||||
|
group.name() === 'field-group' &&
|
||||||
|
!localPageFields.some((field) => field.formId === group.id())
|
||||||
|
) {
|
||||||
|
console.log('Field removed, removing from canvas');
|
||||||
|
group.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If it exists, rerender.
|
||||||
|
localPageFields.forEach((field) => {
|
||||||
|
console.log('Field created/updated, rendering on canvas');
|
||||||
|
renderFieldOnLayer(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
pageLayer.current.batchDraw();
|
||||||
|
}, [localPageFields]);
|
||||||
|
|
||||||
|
if (!currentEnvelopeItem) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||||
|
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
||||||
|
|
||||||
|
<canvas
|
||||||
|
className={`${_className}__canvas z-0`}
|
||||||
|
height={viewport.height}
|
||||||
|
ref={canvasElement}
|
||||||
|
width={viewport.width}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,9 +13,11 @@ import { Separator } from '@documenso/ui/primitives/separator';
|
|||||||
|
|
||||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||||
|
|
||||||
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
|
const EnvelopeEditorPagePreviewRenderer = lazy(
|
||||||
|
async () => import('./envelope-editor-page-preview-renderer'),
|
||||||
|
);
|
||||||
|
|
||||||
export const EnvelopeEditorPreviewPage = () => {
|
export const EnvelopeEditorPagePreview = () => {
|
||||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
@ -49,7 +51,7 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{currentEnvelopeItem !== null ? (
|
{currentEnvelopeItem !== null ? (
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorPagePreviewRenderer} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-32">
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
||||||
@ -41,7 +41,7 @@ type LocalFile = {
|
|||||||
isError: boolean;
|
isError: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EnvelopeEditorUploadPage = () => {
|
export const EnvelopeEditorPageUpload = () => {
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
@ -224,12 +224,8 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
||||||
<Card backdropBlur={false} className="border">
|
<Card backdropBlur={false} className="border">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>
|
<CardTitle>Documents</CardTitle>
|
||||||
<Trans>Documents</Trans>
|
<CardDescription>Add and configure multiple documents</CardDescription>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
<Trans>Add and configure multiple documents</Trans>
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -39,10 +39,10 @@ import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-l
|
|||||||
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { EnvelopeEditorFieldsPage } from './envelope-editor-fields-page';
|
|
||||||
import EnvelopeEditorHeader from './envelope-editor-header';
|
import EnvelopeEditorHeader from './envelope-editor-header';
|
||||||
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
|
import { EnvelopeEditorPageFields } from './envelope-editor-page-fields';
|
||||||
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
|
import { EnvelopeEditorPagePreview } from './envelope-editor-page-preview';
|
||||||
|
import { EnvelopeEditorPageUpload } from './envelope-editor-page-upload';
|
||||||
|
|
||||||
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
||||||
|
|
||||||
@ -128,18 +128,6 @@ export default function EnvelopeEditor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch the URL params and setStep if the step changes.
|
|
||||||
useEffect(() => {
|
|
||||||
const stepParam = searchParams.get('step') || envelopeEditorSteps[0].id;
|
|
||||||
|
|
||||||
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
|
|
||||||
|
|
||||||
if (foundStep && foundStep.id !== currentStep) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
navigateToStep(foundStep.id as EnvelopeEditorStep);
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAutosaving) {
|
if (!isAutosaving) {
|
||||||
setIsStepLoading(false);
|
setIsStepLoading(false);
|
||||||
@ -163,9 +151,7 @@ export default function EnvelopeEditor() {
|
|||||||
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
||||||
|
|
||||||
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
|
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
|
||||||
<Trans context="The step counter">
|
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
||||||
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
|
||||||
</Trans>
|
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -354,12 +340,13 @@ export default function EnvelopeEditor() {
|
|||||||
|
|
||||||
{/* Main Content - Changes based on current step */}
|
{/* Main Content - Changes based on current step */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<p>{isAutosaving ? 'Autosaving...' : 'Not autosaving'}</p>
|
||||||
<AnimateGenericFadeInOut key={currentStep}>
|
<AnimateGenericFadeInOut key={currentStep}>
|
||||||
{match({ currentStep, isStepLoading })
|
{match({ currentStep, isStepLoading })
|
||||||
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
|
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
|
||||||
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
|
.with({ currentStep: 'upload' }, () => <EnvelopeEditorPageUpload />)
|
||||||
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
|
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorPageFields />)
|
||||||
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
|
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPagePreview />)
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
</AnimateGenericFadeInOut>
|
</AnimateGenericFadeInOut>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,31 +1,41 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import type Konva from 'konva';
|
import Konva from 'konva';
|
||||||
|
import type { Layer } from 'konva/lib/Layer';
|
||||||
|
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
import { usePageContext } from 'react-pdf';
|
||||||
|
|
||||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||||
|
|
||||||
export default function EnvelopeGenericPageRenderer() {
|
export default function EnvelopeGenericPageRenderer() {
|
||||||
const { t } = useLingui();
|
const pageContext = usePageContext();
|
||||||
|
|
||||||
|
if (!pageContext) {
|
||||||
|
throw new Error('Unable to find Page context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { _className, page, rotate, scale } = pageContext;
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const {
|
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||||
stage,
|
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||||
pageLayer,
|
|
||||||
canvasElement,
|
|
||||||
konvaContainer,
|
|
||||||
pageContext,
|
|
||||||
scaledViewport,
|
|
||||||
unscaledViewport,
|
|
||||||
} = usePageRenderer(({ stage, pageLayer }) => {
|
|
||||||
createPageCanvas(stage, pageLayer);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { _className, scale } = pageContext;
|
const stage = useRef<Konva.Stage | null>(null);
|
||||||
|
const pageLayer = useRef<Layer | null>(null);
|
||||||
|
|
||||||
|
const viewport = useMemo(
|
||||||
|
() => page.getViewport({ scale, rotation: rotate }),
|
||||||
|
[page, rotate, scale],
|
||||||
|
);
|
||||||
|
|
||||||
const localPageFields = useMemo(
|
const localPageFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -36,6 +46,44 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
[fields, pageContext.pageNumber],
|
[fields, pageContext.pageNumber],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Custom renderer from Konva examples.
|
||||||
|
useEffect(
|
||||||
|
function drawPageOnCanvas() {
|
||||||
|
if (!page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current: canvas } = canvasElement;
|
||||||
|
const { current: container } = konvaContainer;
|
||||||
|
|
||||||
|
if (!canvas || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContext: RenderParameters = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||||
|
viewport,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancellable = page.render(renderContext);
|
||||||
|
const runningTask = cancellable;
|
||||||
|
|
||||||
|
cancellable.promise.catch(() => {
|
||||||
|
// Intentionally empty
|
||||||
|
});
|
||||||
|
|
||||||
|
void cancellable.promise.then(() => {
|
||||||
|
createPageCanvas(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
runningTask.cancel();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[page, viewport],
|
||||||
|
);
|
||||||
|
|
||||||
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
|
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
console.error('Layer not loaded yet');
|
console.error('Layer not loaded yet');
|
||||||
@ -43,7 +91,6 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderField({
|
renderField({
|
||||||
scale,
|
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
field: {
|
field: {
|
||||||
renderId: field.id.toString(),
|
renderId: field.id.toString(),
|
||||||
@ -56,8 +103,8 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
inserted: false,
|
inserted: false,
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
},
|
},
|
||||||
pageWidth: unscaledViewport.width,
|
pageWidth: viewport.width,
|
||||||
pageHeight: unscaledViewport.height,
|
pageHeight: viewport.height,
|
||||||
// color: getRecipientColorKey(field.recipientId),
|
// color: getRecipientColorKey(field.recipientId),
|
||||||
color: 'purple', // Todo
|
color: 'purple', // Todo
|
||||||
editable: false,
|
editable: false,
|
||||||
@ -66,15 +113,25 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Konva page canvas and all fields and interactions.
|
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||||
*/
|
*/
|
||||||
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
const createPageCanvas = (container: HTMLDivElement) => {
|
||||||
|
stage.current = new Konva.Stage({
|
||||||
|
container,
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the main layer for interactive elements.
|
||||||
|
pageLayer.current = new Konva.Layer();
|
||||||
|
stage.current?.add(pageLayer.current);
|
||||||
|
|
||||||
// Render the fields.
|
// Render the fields.
|
||||||
for (const field of localPageFields) {
|
for (const field of localPageFields) {
|
||||||
renderFieldOnLayer(field);
|
renderFieldOnLayer(field);
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPageLayer.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -110,19 +167,14 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||||
className="relative w-full"
|
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
||||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
|
||||||
>
|
|
||||||
{/* The element Konva will inject it's canvas into. */}
|
|
||||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
|
||||||
|
|
||||||
{/* Canvas the PDF will be rendered on. */}
|
|
||||||
<canvas
|
<canvas
|
||||||
className={`${_className}__canvas z-0`}
|
className={`${_className}__canvas z-0`}
|
||||||
|
height={viewport.height}
|
||||||
ref={canvasElement}
|
ref={canvasElement}
|
||||||
height={scaledViewport.height}
|
width={viewport.width}
|
||||||
width={scaledViewport.width}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { type Field, FieldType, type Signature } from '@prisma/client';
|
import { type Field, FieldType } from '@prisma/client';
|
||||||
import type Konva from 'konva';
|
import Konva from 'konva';
|
||||||
|
import type { Layer } from 'konva/lib/Layer';
|
||||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||||
|
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
import { usePageContext } from 'react-pdf';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
||||||
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
||||||
@ -26,6 +28,18 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
|
|||||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||||
|
|
||||||
export default function EnvelopeSignerPageRenderer() {
|
export default function EnvelopeSignerPageRenderer() {
|
||||||
|
const pageContext = usePageContext();
|
||||||
|
|
||||||
|
if (!pageContext) {
|
||||||
|
throw new Error('Unable to find Page context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { _className, page, rotate, scale } = pageContext;
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||||
|
}
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
@ -44,20 +58,21 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
setSignature,
|
setSignature,
|
||||||
} = useRequiredEnvelopeSigningContext();
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
const {
|
console.log({ fullName });
|
||||||
stage,
|
|
||||||
pageLayer,
|
|
||||||
canvasElement,
|
|
||||||
konvaContainer,
|
|
||||||
pageContext,
|
|
||||||
scaledViewport,
|
|
||||||
unscaledViewport,
|
|
||||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
|
||||||
|
|
||||||
const { _className, scale } = pageContext;
|
|
||||||
|
|
||||||
const { envelope } = envelopeData;
|
const { envelope } = envelopeData;
|
||||||
|
|
||||||
|
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||||
|
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const stage = useRef<Konva.Stage | null>(null);
|
||||||
|
const pageLayer = useRef<Layer | null>(null);
|
||||||
|
|
||||||
|
const viewport = useMemo(
|
||||||
|
() => page.getViewport({ scale, rotation: rotate }),
|
||||||
|
[page, rotate, scale],
|
||||||
|
);
|
||||||
|
|
||||||
const localPageFields = useMemo(
|
const localPageFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
recipientFields.filter(
|
recipientFields.filter(
|
||||||
@ -67,7 +82,45 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
[recipientFields, pageContext.pageNumber],
|
[recipientFields, pageContext.pageNumber],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
// Custom renderer from Konva examples.
|
||||||
|
useEffect(
|
||||||
|
function drawPageOnCanvas() {
|
||||||
|
if (!page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current: canvas } = canvasElement;
|
||||||
|
const { current: container } = konvaContainer;
|
||||||
|
|
||||||
|
if (!canvas || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContext: RenderParameters = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||||
|
viewport,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancellable = page.render(renderContext);
|
||||||
|
const runningTask = cancellable;
|
||||||
|
|
||||||
|
cancellable.promise.catch(() => {
|
||||||
|
// Intentionally empty
|
||||||
|
});
|
||||||
|
|
||||||
|
void cancellable.promise.then(() => {
|
||||||
|
createPageCanvas(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
runningTask.cancel();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[page, viewport],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFieldOnLayer = (unparsedField: Field) => {
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
console.error('Layer not loaded yet');
|
console.error('Layer not loaded yet');
|
||||||
return;
|
return;
|
||||||
@ -84,7 +137,6 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { fieldGroup } = renderField({
|
const { fieldGroup } = renderField({
|
||||||
scale,
|
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
field: {
|
field: {
|
||||||
renderId: fieldToRender.id.toString(),
|
renderId: fieldToRender.id.toString(),
|
||||||
@ -93,10 +145,9 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
height: Number(fieldToRender.height),
|
height: Number(fieldToRender.height),
|
||||||
positionX: Number(fieldToRender.positionX),
|
positionX: Number(fieldToRender.positionX),
|
||||||
positionY: Number(fieldToRender.positionY),
|
positionY: Number(fieldToRender.positionY),
|
||||||
signature: unparsedField.signature,
|
|
||||||
},
|
},
|
||||||
pageWidth: unscaledViewport.width,
|
pageWidth: viewport.width,
|
||||||
pageHeight: unscaledViewport.height,
|
pageHeight: viewport.height,
|
||||||
color,
|
color,
|
||||||
mode: 'sign',
|
mode: 'sign',
|
||||||
});
|
});
|
||||||
@ -306,19 +357,29 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Konva page canvas and all fields and interactions.
|
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||||
*/
|
*/
|
||||||
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
const createPageCanvas = (container: HTMLDivElement) => {
|
||||||
|
stage.current = new Konva.Stage({
|
||||||
|
container,
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the main layer for interactive elements.
|
||||||
|
pageLayer.current = new Konva.Layer();
|
||||||
|
stage.current?.add(pageLayer.current);
|
||||||
|
|
||||||
console.log({
|
console.log({
|
||||||
localPageFields,
|
localPageFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render the fields.
|
// Render the fields.
|
||||||
for (const field of localPageFields) {
|
for (const field of localPageFields) {
|
||||||
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
renderFieldOnLayer(field);
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPageLayer.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -331,7 +392,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
|
|
||||||
localPageFields.forEach((field) => {
|
localPageFields.forEach((field) => {
|
||||||
console.log('Field changed/inserted, rendering on canvas');
|
console.log('Field changed/inserted, rendering on canvas');
|
||||||
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
renderFieldOnLayer(field);
|
||||||
});
|
});
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
@ -342,19 +403,14 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||||
className="relative w-full"
|
|
||||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
|
||||||
>
|
|
||||||
{/* The element Konva will inject it's canvas into. */}
|
|
||||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||||
|
|
||||||
{/* Canvas the PDF will be rendered on. */}
|
|
||||||
<canvas
|
<canvas
|
||||||
className={`${_className}__canvas z-0`}
|
className={`${_className}__canvas z-0`}
|
||||||
|
height={viewport.height}
|
||||||
ref={canvasElement}
|
ref={canvasElement}
|
||||||
height={scaledViewport.height}
|
width={viewport.width}
|
||||||
width={scaledViewport.width}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -19,8 +19,6 @@ import { DocumentUploadButton } from '~/components/general/document/document-upl
|
|||||||
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { EnvelopeUploadButton } from '../document/envelope-upload-button';
|
|
||||||
|
|
||||||
export type FolderGridProps = {
|
export type FolderGridProps = {
|
||||||
type: FolderType;
|
type: FolderType;
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
@ -100,7 +98,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
|||||||
|
|
||||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||||
{/* Todo: Envelopes - Feature flag */}
|
{/* Todo: Envelopes - Feature flag */}
|
||||||
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
|
{/* <EnvelopeUploadButton type={type} folderId={parentId || undefined} /> */}
|
||||||
|
|
||||||
{type === FolderType.DOCUMENT ? (
|
{type === FolderType.DOCUMENT ? (
|
||||||
<DocumentUploadButton />
|
<DocumentUploadButton />
|
||||||
|
|||||||
@ -41,6 +41,9 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
|
|||||||
case RecipientStatusType.REJECTED:
|
case RecipientStatusType.REJECTED:
|
||||||
classes = 'bg-red-200 text-red-800';
|
classes = 'bg-red-200 text-red-800';
|
||||||
break;
|
break;
|
||||||
|
case RecipientStatusType.EXPIRED:
|
||||||
|
classes = 'bg-orange-200 text-orange-800';
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,13 +48,20 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
|
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const expiredRecipients = recipients.filter(
|
||||||
|
(recipient) => getRecipientType(recipient) === RecipientStatusType.EXPIRED,
|
||||||
|
);
|
||||||
|
|
||||||
const sortedRecipients = useMemo(() => {
|
const sortedRecipients = useMemo(() => {
|
||||||
const otherRecipients = recipients.filter(
|
const otherRecipients = recipients.filter(
|
||||||
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
|
(recipient) =>
|
||||||
|
getRecipientType(recipient) !== RecipientStatusType.REJECTED &&
|
||||||
|
getRecipientType(recipient) !== RecipientStatusType.EXPIRED,
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...rejectedRecipients.sort((a, b) => a.id - b.id),
|
...rejectedRecipients.sort((a, b) => a.id - b.id),
|
||||||
|
...expiredRecipients.sort((a, b) => a.id - b.id),
|
||||||
...otherRecipients.sort((a, b) => {
|
...otherRecipients.sort((a, b) => {
|
||||||
return a.id - b.id;
|
return a.id - b.id;
|
||||||
}),
|
}),
|
||||||
@ -117,6 +124,30 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{expiredRecipients.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-base font-medium">
|
||||||
|
<Trans>Expired</Trans>
|
||||||
|
</h1>
|
||||||
|
{expiredRecipients.map((recipient: Recipient) => (
|
||||||
|
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||||
|
<StackAvatar
|
||||||
|
first={true}
|
||||||
|
key={recipient.id}
|
||||||
|
type={getRecipientType(recipient)}
|
||||||
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||||
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
|
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{waitingRecipients.length > 0 && (
|
{waitingRecipients.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-medium">
|
<h1 className="text-base font-medium">
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
import { CheckCircle, Clock, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -36,6 +36,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
const isPending = row.status === DocumentStatus.PENDING;
|
const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = isDocumentCompleted(row.status);
|
const isComplete = isDocumentCompleted(row.status);
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
|
||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
|
|
||||||
@ -87,8 +88,15 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
isPending,
|
isPending,
|
||||||
isComplete,
|
isComplete,
|
||||||
isSigned,
|
isSigned,
|
||||||
|
isExpired,
|
||||||
isCurrentTeamDocument,
|
isCurrentTeamDocument,
|
||||||
})
|
})
|
||||||
|
.with({ isRecipient: true, isExpired: true }, () => (
|
||||||
|
<Button className="w-32 bg-orange-100 text-orange-600 hover:bg-orange-200" disabled={true}>
|
||||||
|
<Clock className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
<Trans>Expired</Trans>
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
.with(
|
.with(
|
||||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||||
() => (
|
() => (
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus as DocumentStatusEnum } from '@prisma/client';
|
import { DocumentStatus as DocumentStatusEnum } from '@prisma/client';
|
||||||
import { RecipientRole, SigningStatus } from '@prisma/client';
|
import { RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
|
import { CheckCircleIcon, Clock, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@ -193,6 +193,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
|||||||
const isPending = row.status === DocumentStatusEnum.PENDING;
|
const isPending = row.status === DocumentStatusEnum.PENDING;
|
||||||
const isComplete = isDocumentCompleted(row.status);
|
const isComplete = isDocumentCompleted(row.status);
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
|
||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
|
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
@ -230,7 +231,14 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
|||||||
isPending,
|
isPending,
|
||||||
isComplete,
|
isComplete,
|
||||||
isSigned,
|
isSigned,
|
||||||
|
isExpired,
|
||||||
})
|
})
|
||||||
|
.with({ isExpired: true }, () => (
|
||||||
|
<Button className="w-32 bg-orange-100 text-orange-600 hover:bg-orange-200" disabled={true}>
|
||||||
|
<Clock className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
<Trans>Expired</Trans>
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
.with({ isPending: true, isSigned: false }, () => (
|
.with({ isPending: true, isSigned: false }, () => (
|
||||||
<Button className="w-32" asChild>
|
<Button className="w-32" asChild>
|
||||||
<Link to={`/sign/${recipient?.token}`}>
|
<Link to={`/sign/${recipient?.token}`}>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envel
|
|||||||
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
|
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
|
||||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
|
import { expireRecipient } from '@documenso/lib/server-only/recipient/expire-recipient';
|
||||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||||
import { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
|
import { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
@ -25,6 +26,7 @@ import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settin
|
|||||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { isRecipientExpired } from '@documenso/lib/utils/expiry';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
|
|
||||||
@ -136,6 +138,13 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
|
|||||||
|
|
||||||
const { documentMeta } = document;
|
const { documentMeta } = document;
|
||||||
|
|
||||||
|
if (isRecipientExpired(recipient)) {
|
||||||
|
const expiredRecipient = await expireRecipient({ recipientId: recipient.id });
|
||||||
|
if (expiredRecipient) {
|
||||||
|
throw redirect(`/sign/${token}/expired`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (recipient.signingStatus === SigningStatus.REJECTED) {
|
if (recipient.signingStatus === SigningStatus.REJECTED) {
|
||||||
throw redirect(`/sign/${token}/rejected`);
|
throw redirect(`/sign/${token}/rejected`);
|
||||||
}
|
}
|
||||||
@ -239,6 +248,13 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
|||||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (isRecipientExpired(recipient)) {
|
||||||
|
const expiredRecipient = await expireRecipient({ recipientId: recipient.id });
|
||||||
|
if (expiredRecipient) {
|
||||||
|
throw redirect(`/sign/${token}/expired`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isRejected) {
|
if (isRejected) {
|
||||||
throw redirect(`/sign/${token}/rejected`);
|
throw redirect(`/sign/${token}/rejected`);
|
||||||
}
|
}
|
||||||
|
|||||||
141
apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
Normal file
141
apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
import { Clock8 } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||||
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
|
import { isRecipientExpired } from '@documenso/lib/utils/expiry';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||||
|
import { truncateTitle } from '~/utils/truncate-title';
|
||||||
|
|
||||||
|
import type { Route } from './+types/expired';
|
||||||
|
|
||||||
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const { user } = await getOptionalSession(request);
|
||||||
|
|
||||||
|
const { token } = params;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await getDocumentAndSenderByToken({
|
||||||
|
token,
|
||||||
|
requireAccessAuth: false,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
|
|
||||||
|
const [fields, recipient] = await Promise.all([
|
||||||
|
getFieldsForToken({ token }),
|
||||||
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecipientExpired(recipient)) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||||
|
type: 'ACCESS',
|
||||||
|
documentAuthOptions: document.authOptions,
|
||||||
|
recipient,
|
||||||
|
userId: user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientReference =
|
||||||
|
recipient.name ||
|
||||||
|
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||||
|
recipient.email;
|
||||||
|
|
||||||
|
if (isDocumentAccessValid) {
|
||||||
|
return {
|
||||||
|
isDocumentAccessValid: true,
|
||||||
|
recipientReference,
|
||||||
|
truncatedTitle,
|
||||||
|
recipient,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't leak data if access is denied.
|
||||||
|
return {
|
||||||
|
isDocumentAccessValid: false,
|
||||||
|
recipientReference,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SigningExpiredPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { sessionData } = useOptionalSession();
|
||||||
|
const user = sessionData?.user;
|
||||||
|
|
||||||
|
const { isDocumentAccessValid, recipientReference, truncatedTitle, recipient } = loaderData;
|
||||||
|
|
||||||
|
if (!isDocumentAccessValid) {
|
||||||
|
return <DocumentSigningAuthPageView email={recipientReference} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||||
|
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||||
|
{truncatedTitle}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<Clock8 className="h-10 w-10 text-orange-500" />
|
||||||
|
|
||||||
|
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||||
|
<Trans>Signing Link Expired</Trans>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center text-center text-sm text-orange-600">
|
||||||
|
<Trans>This signing link is no longer valid</Trans>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
|
||||||
|
<Trans>
|
||||||
|
The signing link has expired and can no longer be used to sign the document. Please
|
||||||
|
contact the document sender if you need a new signing link.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{recipient?.expired && (
|
||||||
|
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
|
||||||
|
<Trans>
|
||||||
|
Expired on:{' '}
|
||||||
|
{new Date(recipient.expired).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<Button className="mt-6" asChild>
|
||||||
|
<Link to={`/`}>Return Home</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,7 +4,6 @@ import { ZBaseEmbedDataSchema } from './embed-base-schemas';
|
|||||||
|
|
||||||
export const ZBaseEmbedAuthoringSchema = z
|
export const ZBaseEmbedAuthoringSchema = z
|
||||||
.object({
|
.object({
|
||||||
token: z.string(),
|
|
||||||
externalId: z.string().optional(),
|
externalId: z.string().optional(),
|
||||||
features: z
|
features: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { rateLimiter } from 'hono-rate-limiter';
|
import { rateLimiter } from 'hono-rate-limiter';
|
||||||
import { contextStorage } from 'hono/context-storage';
|
import { contextStorage } from 'hono/context-storage';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
import { requestId } from 'hono/request-id';
|
import { requestId } from 'hono/request-id';
|
||||||
import type { RequestIdVariables } from 'hono/request-id';
|
import type { RequestIdVariables } from 'hono/request-id';
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
@ -83,12 +84,14 @@ app.route('/api/auth', auth);
|
|||||||
app.route('/api/files', filesRoute);
|
app.route('/api/files', filesRoute);
|
||||||
|
|
||||||
// API servers.
|
// API servers.
|
||||||
|
app.use(`/api/v1/*`, cors());
|
||||||
app.route('/api/v1', tsRestHonoApp);
|
app.route('/api/v1', tsRestHonoApp);
|
||||||
app.use('/api/jobs/*', jobsClient.getApiHandler());
|
app.use('/api/jobs/*', jobsClient.getApiHandler());
|
||||||
app.use('/api/trpc/*', reactRouterTrpcServer);
|
app.use('/api/trpc/*', reactRouterTrpcServer);
|
||||||
|
|
||||||
// Unstable API server routes. Order matters for these two.
|
// Unstable API server routes. Order matters for these two.
|
||||||
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
||||||
|
app.use(`${API_V2_BETA_URL}/*`, cors());
|
||||||
app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
|
app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@ -136,7 +136,6 @@ export const useEditorFields = ({
|
|||||||
const field: TLocalField = {
|
const field: TLocalField = {
|
||||||
...fieldData,
|
...fieldData,
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
...restrictFieldPosValues(fieldData),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
append(field);
|
append(field);
|
||||||
@ -166,15 +165,7 @@ export const useEditorFields = ({
|
|||||||
const index = localFields.findIndex((field) => field.formId === formId);
|
const index = localFields.findIndex((field) => field.formId === formId);
|
||||||
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const updatedField = {
|
update(index, { ...localFields[index], ...updates });
|
||||||
...localFields[index],
|
|
||||||
...updates,
|
|
||||||
};
|
|
||||||
|
|
||||||
update(index, {
|
|
||||||
...updatedField,
|
|
||||||
...restrictFieldPosValues(updatedField),
|
|
||||||
});
|
|
||||||
triggerFieldsUpdate();
|
triggerFieldsUpdate();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -288,14 +279,3 @@ export const useEditorFields = ({
|
|||||||
setSelectedRecipient,
|
setSelectedRecipient,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const restrictFieldPosValues = (
|
|
||||||
field: Pick<TLocalField, 'positionX' | 'positionY' | 'width' | 'height'>,
|
|
||||||
) => {
|
|
||||||
return {
|
|
||||||
positionX: Math.max(0, Math.min(100, field.positionX)),
|
|
||||||
positionY: Math.max(0, Math.min(100, field.positionY)),
|
|
||||||
width: Math.max(0, Math.min(100, field.width)),
|
|
||||||
height: Math.max(0, Math.min(100, field.height)),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/consistent-type-assertions */
|
/* eslint-disable @typescript-eslint/consistent-type-assertions */
|
||||||
import { RefObject, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the width and height of a text element.
|
* Calculate the width and height of a text element.
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
|
||||||
|
|
||||||
import Konva from 'konva';
|
|
||||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
|
||||||
import { usePageContext } from 'react-pdf';
|
|
||||||
|
|
||||||
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
|
|
||||||
|
|
||||||
export function usePageRenderer(renderFunction: RenderFunction) {
|
|
||||||
const pageContext = usePageContext();
|
|
||||||
|
|
||||||
if (!pageContext) {
|
|
||||||
throw new Error('Unable to find Page context.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { page, rotate, scale } = pageContext;
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
|
||||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const stage = useRef<Konva.Stage | null>(null);
|
|
||||||
const pageLayer = useRef<Konva.Layer | null>(null);
|
|
||||||
|
|
||||||
const unscaledViewport = useMemo(
|
|
||||||
() => page.getViewport({ scale: 1, rotation: rotate }),
|
|
||||||
[page, rotate, scale],
|
|
||||||
);
|
|
||||||
|
|
||||||
const scaledViewport = useMemo(
|
|
||||||
() => page.getViewport({ scale, rotation: rotate }),
|
|
||||||
[page, rotate, scale],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the PDF and create the scaled Konva stage.
|
|
||||||
*/
|
|
||||||
useEffect(
|
|
||||||
function drawPageOnCanvas() {
|
|
||||||
if (!page) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { current: canvas } = canvasElement;
|
|
||||||
const { current: kContainer } = konvaContainer;
|
|
||||||
|
|
||||||
if (!canvas || !kContainer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderContext: RenderParameters = {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
|
||||||
viewport: scaledViewport,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancellable = page.render(renderContext);
|
|
||||||
const runningTask = cancellable;
|
|
||||||
|
|
||||||
cancellable.promise.catch(() => {
|
|
||||||
// Intentionally empty
|
|
||||||
});
|
|
||||||
|
|
||||||
void cancellable.promise.then(() => {
|
|
||||||
stage.current = new Konva.Stage({
|
|
||||||
container: kContainer,
|
|
||||||
width: scaledViewport.width,
|
|
||||||
height: scaledViewport.height,
|
|
||||||
scale: {
|
|
||||||
x: scale,
|
|
||||||
y: scale,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the main layer for interactive elements.
|
|
||||||
pageLayer.current = new Konva.Layer();
|
|
||||||
|
|
||||||
stage.current.add(pageLayer.current);
|
|
||||||
|
|
||||||
renderFunction({
|
|
||||||
stage: stage.current,
|
|
||||||
pageLayer: pageLayer.current,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
runningTask.cancel();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[page, scaledViewport],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
canvasElement,
|
|
||||||
konvaContainer,
|
|
||||||
stage,
|
|
||||||
pageLayer,
|
|
||||||
unscaledViewport,
|
|
||||||
scaledViewport,
|
|
||||||
pageContext,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -135,12 +135,7 @@ export const EnvelopeEditorProvider = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
|
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
|
||||||
onSuccess: ({ recipients }) => {
|
onSuccess: () => {
|
||||||
setEnvelope((prev) => ({
|
|
||||||
...prev,
|
|
||||||
recipients,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setAutosaveError(false);
|
setAutosaveError(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@ -220,15 +215,14 @@ export const EnvelopeEditorProvider = ({
|
|||||||
|
|
||||||
const getRecipientColorKey = useCallback(
|
const getRecipientColorKey = useCallback(
|
||||||
(recipientId: number) => {
|
(recipientId: number) => {
|
||||||
|
// Todo: Envelopes - Local recipients
|
||||||
const recipientIndex = envelope.recipients.findIndex(
|
const recipientIndex = envelope.recipients.findIndex(
|
||||||
(recipient) => recipient.id === recipientId,
|
(recipient) => recipient.id === recipientId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return AVAILABLE_RECIPIENT_COLORS[
|
return AVAILABLE_RECIPIENT_COLORS[Math.max(recipientIndex, 0)];
|
||||||
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
[envelope.recipients],
|
[envelope.recipients], // Todo: Envelopes - Local recipients
|
||||||
);
|
);
|
||||||
|
|
||||||
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
|
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export enum RecipientStatusType {
|
|||||||
WAITING = 'waiting',
|
WAITING = 'waiting',
|
||||||
UNSIGNED = 'unsigned',
|
UNSIGNED = 'unsigned',
|
||||||
REJECTED = 'rejected',
|
REJECTED = 'rejected',
|
||||||
|
EXPIRED = 'expired',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getRecipientType = (
|
export const getRecipientType = (
|
||||||
@ -27,6 +28,10 @@ export const getRecipientType = (
|
|||||||
return RecipientStatusType.REJECTED;
|
return RecipientStatusType.REJECTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (recipient.signingStatus === SigningStatus.EXPIRED) {
|
||||||
|
return RecipientStatusType.EXPIRED;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
recipient.readStatus === ReadStatus.OPENED &&
|
recipient.readStatus === ReadStatus.OPENED &&
|
||||||
recipient.signingStatus === SigningStatus.NOT_SIGNED
|
recipient.signingStatus === SigningStatus.NOT_SIGNED
|
||||||
@ -52,6 +57,10 @@ export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
|
|||||||
return RecipientStatusType.UNSIGNED;
|
return RecipientStatusType.UNSIGNED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (types.includes(RecipientStatusType.EXPIRED)) {
|
||||||
|
return RecipientStatusType.EXPIRED;
|
||||||
|
}
|
||||||
|
|
||||||
if (types.includes(RecipientStatusType.OPENED)) {
|
if (types.includes(RecipientStatusType.OPENED)) {
|
||||||
return RecipientStatusType.OPENED;
|
return RecipientStatusType.OPENED;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export const getRecipientsStats = async () => {
|
|||||||
[SigningStatus.SIGNED]: 0,
|
[SigningStatus.SIGNED]: 0,
|
||||||
[SigningStatus.NOT_SIGNED]: 0,
|
[SigningStatus.NOT_SIGNED]: 0,
|
||||||
[SigningStatus.REJECTED]: 0,
|
[SigningStatus.REJECTED]: 0,
|
||||||
|
[SigningStatus.EXPIRED]: 0,
|
||||||
[SendStatus.SENT]: 0,
|
[SendStatus.SENT]: 0,
|
||||||
[SendStatus.NOT_SENT]: 0,
|
[SendStatus.NOT_SENT]: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
createDocumentAuditLogData,
|
createDocumentAuditLogData,
|
||||||
diffDocumentMetaChanges,
|
diffDocumentMetaChanges,
|
||||||
} from '@documenso/lib/utils/document-audit-logs';
|
} from '@documenso/lib/utils/document-audit-logs';
|
||||||
|
import { calculateRecipientExpiry } from '@documenso/lib/utils/expiry';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||||
@ -37,6 +38,8 @@ export type CreateDocumentMetaOptions = {
|
|||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
language?: SupportedLanguageCodes;
|
language?: SupportedLanguageCodes;
|
||||||
|
expiryAmount?: number;
|
||||||
|
expiryUnit?: string;
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -59,6 +62,8 @@ export const updateDocumentMeta = async ({
|
|||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: CreateDocumentMetaOptions) => {
|
}: CreateDocumentMetaOptions) => {
|
||||||
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
|
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
|
||||||
@ -120,9 +125,30 @@ export const updateDocumentMeta = async ({
|
|||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (expiryAmount !== undefined || expiryUnit !== undefined) {
|
||||||
|
const newExpiryDate = calculateRecipientExpiry(
|
||||||
|
upsertedDocumentMeta.expiryAmount,
|
||||||
|
upsertedDocumentMeta.expiryUnit,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx.recipient.updateMany({
|
||||||
|
where: {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
signingStatus: { not: 'SIGNED' },
|
||||||
|
role: { not: 'CC' },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
expired: newExpiryDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
|
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
|
||||||
|
|
||||||
// Create audit logs only for document type envelopes.
|
// Create audit logs only for document type envelopes.
|
||||||
|
|||||||
292
packages/lib/server-only/document/create-document-v2.ts
Normal file
292
packages/lib/server-only/document/create-document-v2.ts
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
DocumentSource,
|
||||||
|
FolderType,
|
||||||
|
RecipientRole,
|
||||||
|
SendStatus,
|
||||||
|
SigningStatus,
|
||||||
|
WebhookTriggerEvents,
|
||||||
|
} from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||||
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { TCreateDocumentTemporaryRequest } from '@documenso/trpc/server/document-router/create-document-temporary.types';
|
||||||
|
|
||||||
|
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||||
|
import type { TDocumentFormValues } from '../../types/document-form-values';
|
||||||
|
import {
|
||||||
|
ZWebhookDocumentSchema,
|
||||||
|
mapDocumentToWebhookDocumentPayload,
|
||||||
|
} from '../../types/webhook-payload';
|
||||||
|
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||||
|
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||||
|
import { extractDerivedDocumentMeta } from '../../utils/document';
|
||||||
|
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
||||||
|
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
||||||
|
import { calculateRecipientExpiry } from '../../utils/expiry';
|
||||||
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
|
import { getMemberRoles } from '../team/get-member-roles';
|
||||||
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
|
export type CreateDocumentOptions = {
|
||||||
|
userId: number;
|
||||||
|
teamId: number;
|
||||||
|
documentDataId: string;
|
||||||
|
normalizePdf?: boolean;
|
||||||
|
data: {
|
||||||
|
title: string;
|
||||||
|
externalId?: string;
|
||||||
|
visibility?: DocumentVisibility;
|
||||||
|
globalAccessAuth?: TDocumentAccessAuthTypes[];
|
||||||
|
globalActionAuth?: TDocumentActionAuthTypes[];
|
||||||
|
formValues?: TDocumentFormValues;
|
||||||
|
recipients: TCreateDocumentTemporaryRequest['recipients'];
|
||||||
|
folderId?: string;
|
||||||
|
expiryAmount?: number;
|
||||||
|
expiryUnit?: string;
|
||||||
|
};
|
||||||
|
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
|
||||||
|
requestMetadata: ApiRequestMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDocumentV2 = async ({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
documentDataId,
|
||||||
|
normalizePdf,
|
||||||
|
data,
|
||||||
|
meta,
|
||||||
|
requestMetadata,
|
||||||
|
}: CreateDocumentOptions) => {
|
||||||
|
const { title, formValues, folderId } = data;
|
||||||
|
|
||||||
|
const team = await prisma.team.findFirst({
|
||||||
|
where: buildTeamWhereQuery({ teamId, userId }),
|
||||||
|
include: {
|
||||||
|
organisation: {
|
||||||
|
select: {
|
||||||
|
organisationClaim: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Team not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderId) {
|
||||||
|
const folder = await prisma.folder.findUnique({
|
||||||
|
where: {
|
||||||
|
id: folderId,
|
||||||
|
type: FolderType.DOCUMENT,
|
||||||
|
team: buildTeamWhereQuery({ teamId, userId }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Folder not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await getTeamSettings({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (normalizePdf) {
|
||||||
|
const documentData = await prisma.documentData.findFirst({
|
||||||
|
where: {
|
||||||
|
id: documentDataId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (documentData) {
|
||||||
|
const buffer = await getFileServerSide(documentData);
|
||||||
|
|
||||||
|
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
|
||||||
|
|
||||||
|
const newDocumentData = await putPdfFileServerSide({
|
||||||
|
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
|
||||||
|
type: 'application/pdf',
|
||||||
|
arrayBuffer: async () => Promise.resolve(normalizedPdf),
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line require-atomic-updates
|
||||||
|
documentDataId = newDocumentData.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authOptions = createDocumentAuthOptions({
|
||||||
|
globalAccessAuth: data?.globalAccessAuth || [],
|
||||||
|
globalActionAuth: data?.globalActionAuth || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientsHaveActionAuth = data.recipients?.some(
|
||||||
|
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if user has permission to set the global action auth.
|
||||||
|
if (
|
||||||
|
(authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) &&
|
||||||
|
!team.organisation.organisationClaim.flags.cfr21
|
||||||
|
) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'You do not have permission to set the action auth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamRole } = await getMemberRoles({
|
||||||
|
teamId,
|
||||||
|
reference: {
|
||||||
|
type: 'User',
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibility = determineDocumentVisibility(settings.documentVisibility, teamRole);
|
||||||
|
|
||||||
|
const emailId = meta?.emailId;
|
||||||
|
|
||||||
|
// Validate that the email ID belongs to the organisation.
|
||||||
|
if (emailId) {
|
||||||
|
const email = await prisma.organisationEmail.findFirst({
|
||||||
|
where: {
|
||||||
|
id: emailId,
|
||||||
|
organisationId: team.organisationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Email not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const document = await tx.document.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
qrToken: prefixedId('qr'),
|
||||||
|
externalId: data.externalId,
|
||||||
|
documentDataId,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
authOptions,
|
||||||
|
visibility,
|
||||||
|
folderId,
|
||||||
|
formValues,
|
||||||
|
source: DocumentSource.DOCUMENT,
|
||||||
|
documentMeta: {
|
||||||
|
create: extractDerivedDocumentMeta(settings, {
|
||||||
|
...meta,
|
||||||
|
expiryAmount: data.expiryAmount,
|
||||||
|
expiryUnit: data.expiryUnit,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
(data.recipients || []).map(async (recipient) => {
|
||||||
|
const recipientAuthOptions = createRecipientAuthOptions({
|
||||||
|
accessAuth: recipient.accessAuth ?? [],
|
||||||
|
actionAuth: recipient.actionAuth ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiryDate = calculateRecipientExpiry(
|
||||||
|
data.expiryAmount ?? null,
|
||||||
|
data.expiryUnit ?? null,
|
||||||
|
new Date(), // Calculate from current time
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx.recipient.create({
|
||||||
|
data: {
|
||||||
|
documentId: document.id,
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
role: recipient.role,
|
||||||
|
signingOrder: recipient.signingOrder,
|
||||||
|
token: nanoid(),
|
||||||
|
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||||
|
signingStatus:
|
||||||
|
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||||
|
authOptions: recipientAuthOptions,
|
||||||
|
expired: expiryDate,
|
||||||
|
fields: {
|
||||||
|
createMany: {
|
||||||
|
data: (recipient.fields || []).map((field) => ({
|
||||||
|
documentId: document.id,
|
||||||
|
type: field.type,
|
||||||
|
page: field.pageNumber,
|
||||||
|
positionX: field.pageX,
|
||||||
|
positionY: field.pageY,
|
||||||
|
width: field.width,
|
||||||
|
height: field.height,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Todo: Is it necessary to create a full audit logs with all fields and recipients audit logs?
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||||
|
documentId: document.id,
|
||||||
|
metadata: requestMetadata,
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
source: {
|
||||||
|
type: DocumentSource.DOCUMENT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdDocument = await tx.document.findFirst({
|
||||||
|
where: {
|
||||||
|
id: document.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
documentData: true,
|
||||||
|
documentMeta: true,
|
||||||
|
recipients: true,
|
||||||
|
fields: true,
|
||||||
|
folder: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createdDocument) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Document not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await triggerWebhook({
|
||||||
|
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||||
|
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return createdDocument;
|
||||||
|
});
|
||||||
|
};
|
||||||
177
packages/lib/server-only/document/create-document.ts
Normal file
177
packages/lib/server-only/document/create-document.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
|
||||||
|
import type { DocumentVisibility } from '@prisma/client';
|
||||||
|
|
||||||
|
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import {
|
||||||
|
ZWebhookDocumentSchema,
|
||||||
|
mapDocumentToWebhookDocumentPayload,
|
||||||
|
} from '../../types/webhook-payload';
|
||||||
|
import { prefixedId } from '../../universal/id';
|
||||||
|
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||||
|
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||||
|
import { extractDerivedDocumentMeta } from '../../utils/document';
|
||||||
|
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
||||||
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
|
import { getTeamById } from '../team/get-team';
|
||||||
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
|
export type CreateDocumentOptions = {
|
||||||
|
title: string;
|
||||||
|
externalId?: string | null;
|
||||||
|
userId: number;
|
||||||
|
teamId: number;
|
||||||
|
documentDataId: string;
|
||||||
|
formValues?: Record<string, string | number | boolean>;
|
||||||
|
normalizePdf?: boolean;
|
||||||
|
timezone?: string;
|
||||||
|
userTimezone?: string;
|
||||||
|
requestMetadata: ApiRequestMetadata;
|
||||||
|
folderId?: string;
|
||||||
|
expiryAmount?: number;
|
||||||
|
expiryUnit?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDocument = async ({
|
||||||
|
userId,
|
||||||
|
title,
|
||||||
|
externalId,
|
||||||
|
documentDataId,
|
||||||
|
teamId,
|
||||||
|
normalizePdf,
|
||||||
|
formValues,
|
||||||
|
requestMetadata,
|
||||||
|
timezone,
|
||||||
|
userTimezone,
|
||||||
|
folderId,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
|
}: CreateDocumentOptions) => {
|
||||||
|
const team = await getTeamById({ userId, teamId });
|
||||||
|
|
||||||
|
const settings = await getTeamSettings({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
let folderVisibility: DocumentVisibility | undefined;
|
||||||
|
|
||||||
|
if (folderId) {
|
||||||
|
const folder = await prisma.folder.findFirst({
|
||||||
|
where: {
|
||||||
|
id: folderId,
|
||||||
|
team: buildTeamWhereQuery({
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
visibility: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Folder not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
folderVisibility = folder.visibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizePdf) {
|
||||||
|
const documentData = await prisma.documentData.findFirst({
|
||||||
|
where: {
|
||||||
|
id: documentDataId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (documentData) {
|
||||||
|
const buffer = await getFileServerSide(documentData);
|
||||||
|
|
||||||
|
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
|
||||||
|
|
||||||
|
const newDocumentData = await putPdfFileServerSide({
|
||||||
|
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
|
||||||
|
type: 'application/pdf',
|
||||||
|
arrayBuffer: async () => Promise.resolve(normalizedPdf),
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line require-atomic-updates
|
||||||
|
documentDataId = newDocumentData.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// userTimezone is last because it's always passed in regardless of the organisation/team settings
|
||||||
|
// for uploads from the frontend
|
||||||
|
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
|
||||||
|
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const document = await tx.document.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
qrToken: prefixedId('qr'),
|
||||||
|
externalId,
|
||||||
|
documentDataId,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
folderId,
|
||||||
|
visibility:
|
||||||
|
folderVisibility ??
|
||||||
|
determineDocumentVisibility(settings.documentVisibility, team.currentTeamRole),
|
||||||
|
formValues,
|
||||||
|
source: DocumentSource.DOCUMENT,
|
||||||
|
documentMeta: {
|
||||||
|
create: extractDerivedDocumentMeta(settings, {
|
||||||
|
timezone: timezoneToUse,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||||
|
documentId: document.id,
|
||||||
|
metadata: requestMetadata,
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
source: {
|
||||||
|
type: DocumentSource.DOCUMENT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdDocument = await tx.document.findFirst({
|
||||||
|
where: {
|
||||||
|
id: document.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
documentMeta: true,
|
||||||
|
recipients: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createdDocument) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await triggerWebhook({
|
||||||
|
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||||
|
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return createdDocument;
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -26,6 +26,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
|||||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||||
import { isDocumentCompleted } from '../../utils/document';
|
import { isDocumentCompleted } from '../../utils/document';
|
||||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||||
|
import { calculateRecipientExpiry } from '../../utils/expiry';
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
@ -210,6 +211,39 @@ export const resendDocument = async ({
|
|||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (envelope.documentMeta?.expiryAmount && envelope.documentMeta?.expiryUnit) {
|
||||||
|
const previousExpiryDate = recipient.expired;
|
||||||
|
const newExpiryDate = calculateRecipientExpiry(
|
||||||
|
envelope.documentMeta.expiryAmount,
|
||||||
|
envelope.documentMeta.expiryUnit,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx.recipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipient.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
expired: newExpiryDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
metadata: requestMetadata,
|
||||||
|
data: {
|
||||||
|
recipientId: recipient.id,
|
||||||
|
recipientName: recipient.name,
|
||||||
|
recipientEmail: recipient.email,
|
||||||
|
previousExpiryDate,
|
||||||
|
newExpiryDate,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await tx.documentAuditLog.create({
|
await tx.documentAuditLog.create({
|
||||||
data: createDocumentAuditLogData({
|
data: createDocumentAuditLogData({
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||||
|
|||||||
@ -87,6 +87,7 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
|
|||||||
return {
|
return {
|
||||||
fileName: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
|
fileName: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
|
||||||
content: Buffer.from(file),
|
content: Buffer.from(file),
|
||||||
|
contentType: 'application/pdf',
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
|
|||||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||||
import { isDocumentCompleted } from '../../utils/document';
|
import { isDocumentCompleted } from '../../utils/document';
|
||||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||||
|
import { calculateRecipientExpiry } from '../../utils/expiry';
|
||||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
@ -56,7 +57,6 @@ export const sendDocument = async ({
|
|||||||
recipients: {
|
recipients: {
|
||||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||||
},
|
},
|
||||||
fields: true,
|
|
||||||
documentMeta: true,
|
documentMeta: true,
|
||||||
envelopeItems: {
|
envelopeItems: {
|
||||||
select: {
|
select: {
|
||||||
@ -166,16 +166,6 @@ export const sendDocument = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldsToAutoInsert = [];
|
|
||||||
|
|
||||||
// Todo: Envelopes - Handle auto-signing
|
|
||||||
if (envelope.internalVersion === 2) {
|
|
||||||
// fieldsToAutoInsert = envelope.fields.filter((field) => !field.inserted);
|
|
||||||
// if (fieldsToAutoInsert.length > 0) {
|
|
||||||
// //
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
||||||
if (envelope.status === DocumentStatus.DRAFT) {
|
if (envelope.status === DocumentStatus.DRAFT) {
|
||||||
await tx.documentAuditLog.create({
|
await tx.documentAuditLog.create({
|
||||||
@ -188,6 +178,24 @@ export const sendDocument = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (envelope.documentMeta?.expiryAmount && envelope.documentMeta?.expiryUnit) {
|
||||||
|
const expiryDate = calculateRecipientExpiry(
|
||||||
|
envelope.documentMeta.expiryAmount,
|
||||||
|
envelope.documentMeta.expiryUnit,
|
||||||
|
new Date(), // Calculate from current time
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx.recipient.updateMany({
|
||||||
|
where: {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
expired: null,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
expired: expiryDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return await tx.envelope.update({
|
return await tx.envelope.update({
|
||||||
where: {
|
where: {
|
||||||
id: envelope.id,
|
id: envelope.id,
|
||||||
|
|||||||
@ -156,11 +156,9 @@ export const setFieldsForDocument = async ({
|
|||||||
|
|
||||||
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
||||||
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||||
|
|
||||||
const errors = validateNumberField(
|
const errors = validateNumberField(
|
||||||
String(numberFieldParsedMeta.value),
|
String(numberFieldParsedMeta.value),
|
||||||
numberFieldParsedMeta,
|
numberFieldParsedMeta,
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
|
|||||||
@ -25,7 +25,9 @@ import {
|
|||||||
} from '../../types/field-meta';
|
} from '../../types/field-meta';
|
||||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
|
import { isRecipientExpired } from '../../utils/expiry';
|
||||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||||
|
import { expireRecipient } from '../recipient/expire-recipient';
|
||||||
|
|
||||||
export type SignFieldWithTokenOptions = {
|
export type SignFieldWithTokenOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
@ -115,6 +117,11 @@ export const signFieldWithToken = async ({
|
|||||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRecipientExpired(recipient)) {
|
||||||
|
await expireRecipient({ recipientId: recipient.id });
|
||||||
|
throw new Error(`Signing link has expired`);
|
||||||
|
}
|
||||||
|
|
||||||
if (field.inserted) {
|
if (field.inserted) {
|
||||||
throw new Error(`Field ${fieldId} has already been inserted`);
|
throw new Error(`Field ${fieldId} has already been inserted`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { RotationTypes, radiansToDegrees } from '@cantoo/pdf-lib';
|
|||||||
import fontkit from '@pdf-lib/fontkit';
|
import fontkit from '@pdf-lib/fontkit';
|
||||||
import Konva from 'konva';
|
import Konva from 'konva';
|
||||||
import 'konva/skia-backend';
|
import 'konva/skia-backend';
|
||||||
|
import fs from 'node:fs';
|
||||||
import type { Canvas } from 'skia-canvas';
|
import type { Canvas } from 'skia-canvas';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -85,7 +86,6 @@ export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSigna
|
|||||||
|
|
||||||
// Will render onto the layer.
|
// Will render onto the layer.
|
||||||
renderField({
|
renderField({
|
||||||
scale: 1,
|
|
||||||
field: {
|
field: {
|
||||||
renderId: field.id.toString(),
|
renderId: field.id.toString(),
|
||||||
...field,
|
...field,
|
||||||
@ -105,10 +105,10 @@ export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSigna
|
|||||||
|
|
||||||
const renderedField = await canvas.toBuffer('svg');
|
const renderedField = await canvas.toBuffer('svg');
|
||||||
|
|
||||||
// fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
// `rendered-field-${field.envelopeId}--${field.id}.svg`,
|
`rendered-field-${field.envelopeId}--${field.id}.svg`,
|
||||||
// renderedField.toString('utf-8'),
|
renderedField.toString('utf-8'),
|
||||||
// );
|
);
|
||||||
|
|
||||||
// Embed the SVG into the PDF
|
// Embed the SVG into the PDF
|
||||||
const svgElement = await pdf.embedSvg(renderedField.toString('utf-8'));
|
const svgElement = await pdf.embedSvg(renderedField.toString('utf-8'));
|
||||||
|
|||||||
36
packages/lib/server-only/recipient/expire-recipient.ts
Normal file
36
packages/lib/server-only/recipient/expire-recipient.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { SigningStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type ExpireRecipientOptions = {
|
||||||
|
recipientId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expireRecipient = async ({ recipientId }: ExpireRecipientOptions) => {
|
||||||
|
const recipient = await prisma.recipient.findFirst({
|
||||||
|
where: {
|
||||||
|
id: recipientId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
signingStatus: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.signingStatus === SigningStatus.EXPIRED) {
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.recipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipientId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
signingStatus: SigningStatus.EXPIRED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -52,6 +52,7 @@ import {
|
|||||||
} from '../../utils/document-auth';
|
} from '../../utils/document-auth';
|
||||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||||
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||||
|
import { calculateRecipientExpiry } from '../../utils/expiry';
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
import { incrementDocumentId } from '../envelope/increment-id';
|
import { incrementDocumentId } from '../envelope/increment-id';
|
||||||
@ -110,6 +111,8 @@ export type CreateDocumentFromTemplateOptions = {
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
expiryAmount?: number;
|
||||||
|
expiryUnit?: string;
|
||||||
};
|
};
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
};
|
};
|
||||||
@ -508,6 +511,16 @@ export const createDocumentFromTemplate = async ({
|
|||||||
data: finalRecipients.map((recipient) => {
|
data: finalRecipients.map((recipient) => {
|
||||||
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
|
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
|
||||||
|
|
||||||
|
// Calculate expiry date based on override
|
||||||
|
// Note: Templates no longer have default expiry settings (TemplateMeta removed)
|
||||||
|
const expiryAmount = override?.expiryAmount ?? null;
|
||||||
|
const expiryUnit = override?.expiryUnit ?? null;
|
||||||
|
const recipientExpiryDate = calculateRecipientExpiry(
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
|
new Date(), // Calculate from current time
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
@ -523,6 +536,7 @@ export const createDocumentFromTemplate = async ({
|
|||||||
? SigningStatus.SIGNED
|
? SigningStatus.SIGNED
|
||||||
: SigningStatus.NOT_SIGNED,
|
: SigningStatus.NOT_SIGNED,
|
||||||
signingOrder: recipient.signingOrder,
|
signingOrder: recipient.signingOrder,
|
||||||
|
expired: recipientExpiryDate,
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -267,6 +267,11 @@ msgstr "{prefix} hat das Dokument erstellt"
|
|||||||
msgid "{prefix} deleted the document"
|
msgid "{prefix} deleted the document"
|
||||||
msgstr "{prefix} hat das Dokument gelöscht"
|
msgstr "{prefix} hat das Dokument gelöscht"
|
||||||
|
|
||||||
|
#. placeholder {0}: data.data.recipientEmail
|
||||||
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
|
msgid "{prefix} extended expiry for {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: packages/lib/utils/document-audit-logs.ts
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
msgid "{prefix} moved the document to team"
|
msgid "{prefix} moved the document to team"
|
||||||
msgstr "{prefix} hat das Dokument ins Team verschoben"
|
msgstr "{prefix} hat das Dokument ins Team verschoben"
|
||||||
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
|
|||||||
msgid "Assisting"
|
msgid "Assisting"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: packages/ui/primitives/date-time-picker.tsx
|
||||||
|
msgid "at"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||||
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
||||||
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
|
|||||||
msgstr "Zeitüberschreitung überschritten"
|
msgstr "Zeitüberschreitung überschritten"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||||
|
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||||
|
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||||
|
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
|
||||||
|
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||||
msgid "Expired"
|
msgid "Expired"
|
||||||
msgstr "Abgelaufen"
|
msgstr "Abgelaufen"
|
||||||
|
|
||||||
|
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "Expired on: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||||
msgid "Expires in {0}"
|
msgid "Expires in {0}"
|
||||||
@ -4958,6 +4976,11 @@ msgstr "Link läuft in 1 Stunde ab."
|
|||||||
msgid "Link expires in 30 minutes."
|
msgid "Link expires in 30 minutes."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||||
|
msgid "Link Expiry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
||||||
msgid "Link template"
|
msgid "Link template"
|
||||||
msgstr "Vorlage verlinken"
|
msgstr "Vorlage verlinken"
|
||||||
@ -4979,6 +5002,11 @@ msgstr ""
|
|||||||
msgid "Links Generated"
|
msgid "Links Generated"
|
||||||
msgstr "Links generiert"
|
msgstr "Links generiert"
|
||||||
|
|
||||||
|
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
msgid "Links will expire on: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||||
msgid "Listening to {0}"
|
msgid "Listening to {0}"
|
||||||
@ -5967,6 +5995,10 @@ msgstr "Persönliches Konto"
|
|||||||
msgid "Personal Inbox"
|
msgid "Personal Inbox"
|
||||||
msgstr "Persönlicher Posteingang"
|
msgstr "Persönlicher Posteingang"
|
||||||
|
|
||||||
|
#: packages/ui/primitives/date-time-picker.tsx
|
||||||
|
msgid "Pick a date"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
||||||
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
||||||
msgid "Pick a number"
|
msgid "Pick a number"
|
||||||
@ -6381,6 +6413,10 @@ msgstr "Empfänger"
|
|||||||
msgid "Recipient action authentication"
|
msgid "Recipient action authentication"
|
||||||
msgstr "Empfängeraktion Authentifizierung"
|
msgstr "Empfängeraktion Authentifizierung"
|
||||||
|
|
||||||
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
|
msgid "Recipient expiry extended"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Recipient removed email"
|
msgid "Recipient removed email"
|
||||||
msgstr "E-Mail des entfernten Empfängers"
|
msgstr "E-Mail des entfernten Empfängers"
|
||||||
@ -7095,6 +7131,10 @@ msgstr "Sitzungen wurden widerrufen"
|
|||||||
msgid "Set a password"
|
msgid "Set a password"
|
||||||
msgstr "Ein Passwort festlegen"
|
msgstr "Ein Passwort festlegen"
|
||||||
|
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
msgid "Set an expiry duration for signing links (leave empty to disable)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||||
msgid "Set up your document properties and recipient information"
|
msgid "Set up your document properties and recipient information"
|
||||||
msgstr "Richten Sie Ihre Dokumenteigenschaften und Empfängerinformationen ein"
|
msgstr "Richten Sie Ihre Dokumenteigenschaften und Empfängerinformationen ein"
|
||||||
@ -7393,6 +7433,10 @@ msgstr "Unterzeichne für"
|
|||||||
msgid "Signing in..."
|
msgid "Signing in..."
|
||||||
msgstr "Anmeldung..."
|
msgstr "Anmeldung..."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "Signing Link Expired"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||||
msgid "Signing Links"
|
msgid "Signing Links"
|
||||||
@ -8267,6 +8311,10 @@ msgstr "Der Name des Unterzeichners"
|
|||||||
msgid "The signing link has been copied to your clipboard."
|
msgid "The signing link has been copied to your clipboard."
|
||||||
msgstr "Der Signierlink wurde in die Zwischenablage kopiert."
|
msgstr "Der Signierlink wurde in die Zwischenablage kopiert."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||||
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
||||||
msgstr "Das Seitenbanner ist eine Nachricht, die oben auf der Seite angezeigt wird. Es kann verwendet werden, um Ihren Nutzern wichtige Informationen anzuzeigen."
|
msgstr "Das Seitenbanner ist eine Nachricht, die oben auf der Seite angezeigt wird. Es kann verwendet werden, um Ihren Nutzern wichtige Informationen anzuzeigen."
|
||||||
@ -8585,6 +8633,10 @@ msgstr "Diese Sitzung ist abgelaufen. Bitte versuchen Sie es erneut."
|
|||||||
msgid "This signer has already signed the document."
|
msgid "This signer has already signed the document."
|
||||||
msgstr "Dieser Unterzeichner hat das Dokument bereits unterschrieben."
|
msgstr "Dieser Unterzeichner hat das Dokument bereits unterschrieben."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "This signing link is no longer valid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||||
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
||||||
msgstr "Dieses Team und alle zugehörigen Daten, ausgenommen Rechnungen, werden permanent gelöscht."
|
msgstr "Dieses Team und alle zugehörigen Daten, ausgenommen Rechnungen, werden permanent gelöscht."
|
||||||
|
|||||||
@ -262,6 +262,11 @@ msgstr "{prefix} created the document"
|
|||||||
msgid "{prefix} deleted the document"
|
msgid "{prefix} deleted the document"
|
||||||
msgstr "{prefix} deleted the document"
|
msgstr "{prefix} deleted the document"
|
||||||
|
|
||||||
|
#. placeholder {0}: data.data.recipientEmail
|
||||||
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
|
msgid "{prefix} extended expiry for {0}"
|
||||||
|
msgstr "{prefix} extended expiry for {0}"
|
||||||
|
|
||||||
#: packages/lib/utils/document-audit-logs.ts
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
msgid "{prefix} moved the document to team"
|
msgid "{prefix} moved the document to team"
|
||||||
msgstr "{prefix} moved the document to team"
|
msgstr "{prefix} moved the document to team"
|
||||||
@ -1700,6 +1705,10 @@ msgctxt "Recipient role progressive verb"
|
|||||||
msgid "Assisting"
|
msgid "Assisting"
|
||||||
msgstr "Assisting"
|
msgstr "Assisting"
|
||||||
|
|
||||||
|
#: packages/ui/primitives/date-time-picker.tsx
|
||||||
|
msgid "at"
|
||||||
|
msgstr "at"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||||
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
||||||
@ -4071,9 +4080,18 @@ msgid "Exceeded timeout"
|
|||||||
msgstr "Exceeded timeout"
|
msgstr "Exceeded timeout"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||||
|
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||||
|
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||||
|
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
|
||||||
|
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||||
msgid "Expired"
|
msgid "Expired"
|
||||||
msgstr "Expired"
|
msgstr "Expired"
|
||||||
|
|
||||||
|
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "Expired on: {0}"
|
||||||
|
msgstr "Expired on: {0}"
|
||||||
|
|
||||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||||
msgid "Expires in {0}"
|
msgid "Expires in {0}"
|
||||||
@ -4953,6 +4971,11 @@ msgstr "Link expires in 1 hour."
|
|||||||
msgid "Link expires in 30 minutes."
|
msgid "Link expires in 30 minutes."
|
||||||
msgstr "Link expires in 30 minutes."
|
msgstr "Link expires in 30 minutes."
|
||||||
|
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||||
|
msgid "Link Expiry"
|
||||||
|
msgstr "Link Expiry"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
||||||
msgid "Link template"
|
msgid "Link template"
|
||||||
msgstr "Link template"
|
msgstr "Link template"
|
||||||
@ -4974,6 +4997,11 @@ msgstr "Linked At"
|
|||||||
msgid "Links Generated"
|
msgid "Links Generated"
|
||||||
msgstr "Links Generated"
|
msgstr "Links Generated"
|
||||||
|
|
||||||
|
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
msgid "Links will expire on: {0}"
|
||||||
|
msgstr "Links will expire on: {0}"
|
||||||
|
|
||||||
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||||
msgid "Listening to {0}"
|
msgid "Listening to {0}"
|
||||||
@ -5962,6 +5990,10 @@ msgstr "Personal Account"
|
|||||||
msgid "Personal Inbox"
|
msgid "Personal Inbox"
|
||||||
msgstr "Personal Inbox"
|
msgstr "Personal Inbox"
|
||||||
|
|
||||||
|
#: packages/ui/primitives/date-time-picker.tsx
|
||||||
|
msgid "Pick a date"
|
||||||
|
msgstr "Pick a date"
|
||||||
|
|
||||||
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
||||||
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
||||||
msgid "Pick a number"
|
msgid "Pick a number"
|
||||||
@ -6376,6 +6408,10 @@ msgstr "Recipient"
|
|||||||
msgid "Recipient action authentication"
|
msgid "Recipient action authentication"
|
||||||
msgstr "Recipient action authentication"
|
msgstr "Recipient action authentication"
|
||||||
|
|
||||||
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
|
msgid "Recipient expiry extended"
|
||||||
|
msgstr "Recipient expiry extended"
|
||||||
|
|
||||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Recipient removed email"
|
msgid "Recipient removed email"
|
||||||
msgstr "Recipient removed email"
|
msgstr "Recipient removed email"
|
||||||
@ -7090,6 +7126,10 @@ msgstr "Sessions have been revoked"
|
|||||||
msgid "Set a password"
|
msgid "Set a password"
|
||||||
msgstr "Set a password"
|
msgstr "Set a password"
|
||||||
|
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
msgid "Set an expiry duration for signing links (leave empty to disable)"
|
||||||
|
msgstr "Set an expiry duration for signing links (leave empty to disable)"
|
||||||
|
|
||||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||||
msgid "Set up your document properties and recipient information"
|
msgid "Set up your document properties and recipient information"
|
||||||
msgstr "Set up your document properties and recipient information"
|
msgstr "Set up your document properties and recipient information"
|
||||||
@ -7388,6 +7428,10 @@ msgstr "Signing for"
|
|||||||
msgid "Signing in..."
|
msgid "Signing in..."
|
||||||
msgstr "Signing in..."
|
msgstr "Signing in..."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "Signing Link Expired"
|
||||||
|
msgstr "Signing Link Expired"
|
||||||
|
|
||||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||||
msgid "Signing Links"
|
msgid "Signing Links"
|
||||||
@ -8272,6 +8316,10 @@ msgstr "The signer's name"
|
|||||||
msgid "The signing link has been copied to your clipboard."
|
msgid "The signing link has been copied to your clipboard."
|
||||||
msgstr "The signing link has been copied to your clipboard."
|
msgstr "The signing link has been copied to your clipboard."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
|
||||||
|
msgstr "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||||
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
||||||
msgstr "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
msgstr "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
||||||
@ -8600,6 +8648,10 @@ msgstr "This session has expired. Please try again."
|
|||||||
msgid "This signer has already signed the document."
|
msgid "This signer has already signed the document."
|
||||||
msgstr "This signer has already signed the document."
|
msgstr "This signer has already signed the document."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "This signing link is no longer valid"
|
||||||
|
msgstr "This signing link is no longer valid"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||||
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
||||||
msgstr "This team, and any associated data excluding billing invoices will be permanently deleted."
|
msgstr "This team, and any associated data excluding billing invoices will be permanently deleted."
|
||||||
|
|||||||
@ -267,6 +267,11 @@ msgstr "{prefix} creó el documento"
|
|||||||
msgid "{prefix} deleted the document"
|
msgid "{prefix} deleted the document"
|
||||||
msgstr "{prefix} eliminó el documento"
|
msgstr "{prefix} eliminó el documento"
|
||||||
|
|
||||||
|
#. placeholder {0}: data.data.recipientEmail
|
||||||
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
|
msgid "{prefix} extended expiry for {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: packages/lib/utils/document-audit-logs.ts
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
msgid "{prefix} moved the document to team"
|
msgid "{prefix} moved the document to team"
|
||||||
msgstr "{prefix} movió el documento al equipo"
|
msgstr "{prefix} movió el documento al equipo"
|
||||||
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
|
|||||||
msgid "Assisting"
|
msgid "Assisting"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: packages/ui/primitives/date-time-picker.tsx
|
||||||
|
msgid "at"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||||
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
||||||
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
|
|||||||
msgstr "Tiempo de espera excedido"
|
msgstr "Tiempo de espera excedido"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||||
|
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||||
|
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||||
|
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
|
||||||
|
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||||
msgid "Expired"
|
msgid "Expired"
|
||||||
msgstr "Expirado"
|
msgstr "Expirado"
|
||||||
|
|
||||||
|
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "Expired on: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||||
msgid "Expires in {0}"
|
msgid "Expires in {0}"
|
||||||
@ -4958,6 +4976,11 @@ msgstr "El enlace expira en 1 hora."
|
|||||||
msgid "Link expires in 30 minutes."
|
msgid "Link expires in 30 minutes."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||||
|
msgid "Link Expiry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
||||||
msgid "Link template"
|
msgid "Link template"
|
||||||
msgstr "Enlace de plantilla"
|
msgstr "Enlace de plantilla"
|
||||||
@ -4979,6 +5002,11 @@ msgstr ""
|
|||||||
msgid "Links Generated"
|
msgid "Links Generated"
|
||||||
msgstr "Enlaces generados"
|
msgstr "Enlaces generados"
|
||||||
|
|
||||||
|
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
msgid "Links will expire on: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||||
msgid "Listening to {0}"
|
msgid "Listening to {0}"
|
||||||
@ -5967,6 +5995,10 @@ msgstr "Cuenta personal"
|
|||||||
msgid "Personal Inbox"
|
msgid "Personal Inbox"
|
||||||
msgstr "Bandeja de entrada personal"
|
msgstr "Bandeja de entrada personal"
|
||||||
|
|
||||||
|
#: packages/ui/primitives/date-time-picker.tsx
|
||||||
|
msgid "Pick a date"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
||||||
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
||||||
msgid "Pick a number"
|
msgid "Pick a number"
|
||||||
@ -6381,6 +6413,10 @@ msgstr "Destinatario"
|
|||||||
msgid "Recipient action authentication"
|
msgid "Recipient action authentication"
|
||||||
msgstr "Autenticación de acción de destinatario"
|
msgstr "Autenticación de acción de destinatario"
|
||||||
|
|
||||||
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
|
msgid "Recipient expiry extended"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Recipient removed email"
|
msgid "Recipient removed email"
|
||||||
msgstr "Correo electrónico de destinatario eliminado"
|
msgstr "Correo electrónico de destinatario eliminado"
|
||||||
@ -7095,6 +7131,10 @@ msgstr "Las sesiones han sido revocadas"
|
|||||||
msgid "Set a password"
|
msgid "Set a password"
|
||||||
msgstr "Establecer una contraseña"
|
msgstr "Establecer una contraseña"
|
||||||
|
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
msgid "Set an expiry duration for signing links (leave empty to disable)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||||
msgid "Set up your document properties and recipient information"
|
msgid "Set up your document properties and recipient information"
|
||||||
msgstr "Configura las propiedades de tu documento y la información del destinatario"
|
msgstr "Configura las propiedades de tu documento y la información del destinatario"
|
||||||
@ -7393,6 +7433,10 @@ msgstr "Firmando para"
|
|||||||
msgid "Signing in..."
|
msgid "Signing in..."
|
||||||
msgstr "Iniciando sesión..."
|
msgstr "Iniciando sesión..."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "Signing Link Expired"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||||
msgid "Signing Links"
|
msgid "Signing Links"
|
||||||
@ -8267,6 +8311,10 @@ msgstr "El nombre del firmante"
|
|||||||
msgid "The signing link has been copied to your clipboard."
|
msgid "The signing link has been copied to your clipboard."
|
||||||
msgstr "El enlace de firma ha sido copiado a tu portapapeles."
|
msgstr "El enlace de firma ha sido copiado a tu portapapeles."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||||
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
||||||
msgstr "El banner del sitio es un mensaje que se muestra en la parte superior del sitio. Se puede usar para mostrar información importante a tus usuarios."
|
msgstr "El banner del sitio es un mensaje que se muestra en la parte superior del sitio. Se puede usar para mostrar información importante a tus usuarios."
|
||||||
@ -8587,6 +8635,10 @@ msgstr "Esta sesión ha expirado. Por favor, inténtalo de nuevo."
|
|||||||
msgid "This signer has already signed the document."
|
msgid "This signer has already signed the document."
|
||||||
msgstr "Este firmante ya ha firmado el documento."
|
msgstr "Este firmante ya ha firmado el documento."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "This signing link is no longer valid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||||
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
||||||
msgstr "Este equipo, y cualquier dato asociado, excluyendo las facturas de facturación, serán eliminados permanentemente."
|
msgstr "Este equipo, y cualquier dato asociado, excluyendo las facturas de facturación, serán eliminados permanentemente."
|
||||||
|
|||||||
@ -267,6 +267,11 @@ msgstr "{prefix} a créé le document"
|
|||||||
msgid "{prefix} deleted the document"
|
msgid "{prefix} deleted the document"
|
||||||
msgstr "{prefix} a supprimé le document"
|
msgstr "{prefix} a supprimé le document"
|
||||||
|
|
||||||
|
#. placeholder {0}: data.data.recipientEmail
|
||||||
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
|
msgid "{prefix} extended expiry for {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: packages/lib/utils/document-audit-logs.ts
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
msgid "{prefix} moved the document to team"
|
msgid "{prefix} moved the document to team"
|
||||||
msgstr "{prefix} a déplacé le document vers l'équipe"
|
msgstr "{prefix} a déplacé le document vers l'équipe"
|
||||||
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
|
|||||||
msgid "Assisting"
|
msgid "Assisting"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: packages/ui/primitives/date-time-picker.tsx
|
||||||
|
msgid "at"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||||
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
||||||
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
|
|||||||
msgstr "Délai dépassé"
|
msgstr "Délai dépassé"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||||
|
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||||
|
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||||
|
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
|
||||||
|
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||||
msgid "Expired"
|
msgid "Expired"
|
||||||
msgstr "Expiré"
|
msgstr "Expiré"
|
||||||
|
|
||||||
|
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "Expired on: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||||
msgid "Expires in {0}"
|
msgid "Expires in {0}"
|
||||||
@ -4958,6 +4976,11 @@ msgstr "Le lien expire dans 1 heure."
|
|||||||
msgid "Link expires in 30 minutes."
|
msgid "Link expires in 30 minutes."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||||
|
msgid "Link Expiry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
||||||
msgid "Link template"
|
msgid "Link template"
|
||||||
msgstr "Modèle de lien"
|
msgstr "Modèle de lien"
|
||||||
@ -4979,6 +5002,11 @@ msgstr ""
|
|||||||
msgid "Links Generated"
|
msgid "Links Generated"
|
||||||
msgstr "Liens générés"
|
msgstr "Liens générés"
|
||||||
|
|
||||||
|
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
msgid "Links will expire on: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||||
msgid "Listening to {0}"
|
msgid "Listening to {0}"
|
||||||
@ -5967,6 +5995,10 @@ msgstr "Compte personnel"
|
|||||||
msgid "Personal Inbox"
|
msgid "Personal Inbox"
|
||||||
msgstr "Boîte de réception personnelle"
|
msgstr "Boîte de réception personnelle"
|
||||||
|
|
||||||
|
#: packages/ui/primitives/date-time-picker.tsx
|
||||||
|
msgid "Pick a date"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
||||||
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
||||||
msgid "Pick a number"
|
msgid "Pick a number"
|
||||||
@ -6381,6 +6413,10 @@ msgstr "Destinataire"
|
|||||||
msgid "Recipient action authentication"
|
msgid "Recipient action authentication"
|
||||||
msgstr "Authentification d'action de destinataire"
|
msgstr "Authentification d'action de destinataire"
|
||||||
|
|
||||||
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
|
msgid "Recipient expiry extended"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Recipient removed email"
|
msgid "Recipient removed email"
|
||||||
msgstr "E-mail de destinataire supprimé"
|
msgstr "E-mail de destinataire supprimé"
|
||||||
@ -7095,6 +7131,10 @@ msgstr "Les sessions ont été révoquées"
|
|||||||
msgid "Set a password"
|
msgid "Set a password"
|
||||||
msgstr "Définir un mot de passe"
|
msgstr "Définir un mot de passe"
|
||||||
|
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
msgid "Set an expiry duration for signing links (leave empty to disable)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||||
msgid "Set up your document properties and recipient information"
|
msgid "Set up your document properties and recipient information"
|
||||||
msgstr "Configurez les propriétés de votre document et les informations du destinataire"
|
msgstr "Configurez les propriétés de votre document et les informations du destinataire"
|
||||||
@ -7393,6 +7433,10 @@ msgstr "Signé pour"
|
|||||||
msgid "Signing in..."
|
msgid "Signing in..."
|
||||||
msgstr "Connexion en cours..."
|
msgstr "Connexion en cours..."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "Signing Link Expired"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||||
msgid "Signing Links"
|
msgid "Signing Links"
|
||||||
@ -8267,6 +8311,10 @@ msgstr "Le nom du signataire"
|
|||||||
msgid "The signing link has been copied to your clipboard."
|
msgid "The signing link has been copied to your clipboard."
|
||||||
msgstr "Le lien de signature a été copié dans votre presse-papiers."
|
msgstr "Le lien de signature a été copié dans votre presse-papiers."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||||
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
||||||
msgstr "La bannière du site est un message affiché en haut du site. Elle peut être utilisée pour afficher des informations importantes à vos utilisateurs."
|
msgstr "La bannière du site est un message affiché en haut du site. Elle peut être utilisée pour afficher des informations importantes à vos utilisateurs."
|
||||||
@ -8585,6 +8633,10 @@ msgstr "Cette session a expiré. Veuillez réessayer."
|
|||||||
msgid "This signer has already signed the document."
|
msgid "This signer has already signed the document."
|
||||||
msgstr "Ce signataire a déjà signé le document."
|
msgstr "Ce signataire a déjà signé le document."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "This signing link is no longer valid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||||
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
||||||
msgstr "Cette équipe, et toutes les données associées à l'exception des factures de facturation, seront définitivement supprimées."
|
msgstr "Cette équipe, et toutes les données associées à l'exception des factures de facturation, seront définitivement supprimées."
|
||||||
|
|||||||
@ -267,6 +267,11 @@ msgstr "{prefix} ha creato il documento"
|
|||||||
msgid "{prefix} deleted the document"
|
msgid "{prefix} deleted the document"
|
||||||
msgstr "{prefix} ha eliminato il documento"
|
msgstr "{prefix} ha eliminato il documento"
|
||||||
|
|
||||||
|
#. placeholder {0}: data.data.recipientEmail
|
||||||
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
|
msgid "{prefix} extended expiry for {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: packages/lib/utils/document-audit-logs.ts
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
msgid "{prefix} moved the document to team"
|
msgid "{prefix} moved the document to team"
|
||||||
msgstr "{prefix} ha spostato il documento al team"
|
msgstr "{prefix} ha spostato il documento al team"
|
||||||
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
|
|||||||
msgid "Assisting"
|
msgid "Assisting"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: packages/ui/primitives/date-time-picker.tsx
|
||||||
|
msgid "at"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||||
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
||||||
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
|
|||||||
msgstr "Tempo scaduto"
|
msgstr "Tempo scaduto"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||||
|
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||||
|
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||||
|
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
|
||||||
|
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||||
msgid "Expired"
|
msgid "Expired"
|
||||||
msgstr "Scaduto"
|
msgstr "Scaduto"
|
||||||
|
|
||||||
|
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "Expired on: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||||
msgid "Expires in {0}"
|
msgid "Expires in {0}"
|
||||||
@ -4958,6 +4976,11 @@ msgstr "Il link scade tra 1 ora."
|
|||||||
msgid "Link expires in 30 minutes."
|
msgid "Link expires in 30 minutes."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||||
|
msgid "Link Expiry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
||||||
msgid "Link template"
|
msgid "Link template"
|
||||||
msgstr "Collega modello"
|
msgstr "Collega modello"
|
||||||
@ -4979,6 +5002,11 @@ msgstr ""
|
|||||||
msgid "Links Generated"
|
msgid "Links Generated"
|
||||||
msgstr "Link Generati"
|
msgstr "Link Generati"
|
||||||
|
|
||||||
|
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
msgid "Links will expire on: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||||
msgid "Listening to {0}"
|
msgid "Listening to {0}"
|
||||||
@ -5967,6 +5995,10 @@ msgstr "Account personale"
|
|||||||
msgid "Personal Inbox"
|
msgid "Personal Inbox"
|
||||||
msgstr "Posta in arrivo personale"
|
msgstr "Posta in arrivo personale"
|
||||||
|
|
||||||
|
#: packages/ui/primitives/date-time-picker.tsx
|
||||||
|
msgid "Pick a date"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
||||||
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
||||||
msgid "Pick a number"
|
msgid "Pick a number"
|
||||||
@ -6381,6 +6413,10 @@ msgstr "Destinatario"
|
|||||||
msgid "Recipient action authentication"
|
msgid "Recipient action authentication"
|
||||||
msgstr "Autenticazione azione destinatario"
|
msgstr "Autenticazione azione destinatario"
|
||||||
|
|
||||||
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
|
msgid "Recipient expiry extended"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Recipient removed email"
|
msgid "Recipient removed email"
|
||||||
msgstr "Email destinatario rimosso"
|
msgstr "Email destinatario rimosso"
|
||||||
@ -7095,6 +7131,10 @@ msgstr "Le sessioni sono state revocate"
|
|||||||
msgid "Set a password"
|
msgid "Set a password"
|
||||||
msgstr "Imposta una password"
|
msgstr "Imposta una password"
|
||||||
|
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
msgid "Set an expiry duration for signing links (leave empty to disable)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||||
msgid "Set up your document properties and recipient information"
|
msgid "Set up your document properties and recipient information"
|
||||||
msgstr "Configura le proprietà del documento e le informazioni sui destinatari"
|
msgstr "Configura le proprietà del documento e le informazioni sui destinatari"
|
||||||
@ -7393,6 +7433,10 @@ msgstr "Firma per"
|
|||||||
msgid "Signing in..."
|
msgid "Signing in..."
|
||||||
msgstr "Accesso in corso..."
|
msgstr "Accesso in corso..."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "Signing Link Expired"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||||
msgid "Signing Links"
|
msgid "Signing Links"
|
||||||
@ -8275,6 +8319,10 @@ msgstr "Il nome del firmatario"
|
|||||||
msgid "The signing link has been copied to your clipboard."
|
msgid "The signing link has been copied to your clipboard."
|
||||||
msgstr "Il link di firma è stato copiato negli appunti."
|
msgstr "Il link di firma è stato copiato negli appunti."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||||
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
||||||
msgstr "Il banner del sito è un messaggio che viene mostrato in cima al sito. Può essere utilizzato per visualizzare informazioni importanti ai tuoi utenti."
|
msgstr "Il banner del sito è un messaggio che viene mostrato in cima al sito. Può essere utilizzato per visualizzare informazioni importanti ai tuoi utenti."
|
||||||
@ -8601,6 +8649,10 @@ msgstr "Questa sessione è scaduta. Per favore prova di nuovo."
|
|||||||
msgid "This signer has already signed the document."
|
msgid "This signer has already signed the document."
|
||||||
msgstr "Questo firmatario ha già firmato il documento."
|
msgstr "Questo firmatario ha già firmato il documento."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "This signing link is no longer valid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||||
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
||||||
msgstr "Questo team e tutti i dati associati, escluse le fatture di fatturazione, verranno eliminati definitivamente."
|
msgstr "Questo team e tutti i dati associati, escluse le fatture di fatturazione, verranno eliminati definitivamente."
|
||||||
|
|||||||
@ -267,6 +267,11 @@ msgstr "Użytkownik {prefix} utworzył dokument"
|
|||||||
msgid "{prefix} deleted the document"
|
msgid "{prefix} deleted the document"
|
||||||
msgstr "Użytkownik {prefix} usunął dokument"
|
msgstr "Użytkownik {prefix} usunął dokument"
|
||||||
|
|
||||||
|
#. placeholder {0}: data.data.recipientEmail
|
||||||
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
|
msgid "{prefix} extended expiry for {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: packages/lib/utils/document-audit-logs.ts
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
msgid "{prefix} moved the document to team"
|
msgid "{prefix} moved the document to team"
|
||||||
msgstr "Użytkownik {prefix} przeniósł dokument do zespołu"
|
msgstr "Użytkownik {prefix} przeniósł dokument do zespołu"
|
||||||
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
|
|||||||
msgid "Assisting"
|
msgid "Assisting"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: packages/ui/primitives/date-time-picker.tsx
|
||||||
|
msgid "at"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||||
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
||||||
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
|
|||||||
msgstr "Przekroczono limit czasu"
|
msgstr "Przekroczono limit czasu"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||||
|
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||||
|
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||||
|
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
|
||||||
|
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||||
msgid "Expired"
|
msgid "Expired"
|
||||||
msgstr "Wygasł"
|
msgstr "Wygasł"
|
||||||
|
|
||||||
|
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "Expired on: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||||
msgid "Expires in {0}"
|
msgid "Expires in {0}"
|
||||||
@ -4958,6 +4976,11 @@ msgstr "Link wygaśnie za 1 godzinę."
|
|||||||
msgid "Link expires in 30 minutes."
|
msgid "Link expires in 30 minutes."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||||
|
msgid "Link Expiry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
||||||
msgid "Link template"
|
msgid "Link template"
|
||||||
msgstr "Szablon linku"
|
msgstr "Szablon linku"
|
||||||
@ -4979,6 +5002,11 @@ msgstr ""
|
|||||||
msgid "Links Generated"
|
msgid "Links Generated"
|
||||||
msgstr "Wygenerowane linki"
|
msgstr "Wygenerowane linki"
|
||||||
|
|
||||||
|
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
msgid "Links will expire on: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||||
msgid "Listening to {0}"
|
msgid "Listening to {0}"
|
||||||
@ -5967,6 +5995,10 @@ msgstr "Konto osobiste"
|
|||||||
msgid "Personal Inbox"
|
msgid "Personal Inbox"
|
||||||
msgstr "Skrzynka odbiorcza osobista"
|
msgstr "Skrzynka odbiorcza osobista"
|
||||||
|
|
||||||
|
#: packages/ui/primitives/date-time-picker.tsx
|
||||||
|
msgid "Pick a date"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
||||||
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
||||||
msgid "Pick a number"
|
msgid "Pick a number"
|
||||||
@ -6381,6 +6413,10 @@ msgstr "Odbiorca"
|
|||||||
msgid "Recipient action authentication"
|
msgid "Recipient action authentication"
|
||||||
msgstr "Uwierzytelnianie odbiorcy"
|
msgstr "Uwierzytelnianie odbiorcy"
|
||||||
|
|
||||||
|
#: packages/lib/utils/document-audit-logs.ts
|
||||||
|
msgid "Recipient expiry extended"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Recipient removed email"
|
msgid "Recipient removed email"
|
||||||
msgstr "Wiadomość o usuniętym odbiorcy"
|
msgstr "Wiadomość o usuniętym odbiorcy"
|
||||||
@ -7095,6 +7131,10 @@ msgstr "Sesje zostały odwołane"
|
|||||||
msgid "Set a password"
|
msgid "Set a password"
|
||||||
msgstr "Ustaw hasło"
|
msgstr "Ustaw hasło"
|
||||||
|
|
||||||
|
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||||
|
msgid "Set an expiry duration for signing links (leave empty to disable)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||||
msgid "Set up your document properties and recipient information"
|
msgid "Set up your document properties and recipient information"
|
||||||
msgstr "Skonfiguruj właściwości dokumentu i informacje o odbiorcach"
|
msgstr "Skonfiguruj właściwości dokumentu i informacje o odbiorcach"
|
||||||
@ -7393,6 +7433,10 @@ msgstr "Podpis w imieniu"
|
|||||||
msgid "Signing in..."
|
msgid "Signing in..."
|
||||||
msgstr "Logowanie..."
|
msgstr "Logowanie..."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "Signing Link Expired"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||||
msgid "Signing Links"
|
msgid "Signing Links"
|
||||||
@ -8267,6 +8311,10 @@ msgstr "Nazwa podpisującego"
|
|||||||
msgid "The signing link has been copied to your clipboard."
|
msgid "The signing link has been copied to your clipboard."
|
||||||
msgstr "Link do podpisu został skopiowany do schowka."
|
msgstr "Link do podpisu został skopiowany do schowka."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||||
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
||||||
msgstr "Baner strony to wiadomość, która jest wyświetlana u góry strony. Może być używany do wyświetlania ważnych informacji użytkownikom."
|
msgstr "Baner strony to wiadomość, która jest wyświetlana u góry strony. Może być używany do wyświetlania ważnych informacji użytkownikom."
|
||||||
@ -8585,6 +8633,10 @@ msgstr "Ta sesja wygasła. Proszę spróbować ponownie."
|
|||||||
msgid "This signer has already signed the document."
|
msgid "This signer has already signed the document."
|
||||||
msgstr "Ten sygnatariusz już podpisał dokument."
|
msgstr "Ten sygnatariusz już podpisał dokument."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||||
|
msgid "This signing link is no longer valid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||||
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
||||||
msgstr "Ten zespół oraz wszelkie powiązane dane, z wyjątkiem faktur, zostaną trwale usunięte."
|
msgstr "Ten zespół oraz wszelkie powiązane dane, z wyjątkiem faktur, zostaną trwale usunięte."
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
|||||||
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
||||||
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
|
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
|
||||||
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
|
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
|
||||||
|
'DOCUMENT_RECIPIENT_EXPIRY_EXTENDED', // When a recipient's expiry is extended via resend.
|
||||||
|
|
||||||
// ACCESS AUTH 2FA events.
|
// ACCESS AUTH 2FA events.
|
||||||
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
|
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
|
||||||
@ -639,6 +640,20 @@ export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Recipient expiry extended.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventRecipientExpiryExtendedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED),
|
||||||
|
data: z.object({
|
||||||
|
recipientId: z.number(),
|
||||||
|
recipientName: z.string().optional(),
|
||||||
|
recipientEmail: z.string(),
|
||||||
|
previousExpiryDate: z.date().nullable(),
|
||||||
|
newExpiryDate: z.date().nullable(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const ZDocumentAuditLogBaseSchema = z.object({
|
export const ZDocumentAuditLogBaseSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
@ -680,6 +695,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
|||||||
ZDocumentAuditLogEventRecipientAddedSchema,
|
ZDocumentAuditLogEventRecipientAddedSchema,
|
||||||
ZDocumentAuditLogEventRecipientUpdatedSchema,
|
ZDocumentAuditLogEventRecipientUpdatedSchema,
|
||||||
ZDocumentAuditLogEventRecipientRemovedSchema,
|
ZDocumentAuditLogEventRecipientRemovedSchema,
|
||||||
|
ZDocumentAuditLogEventRecipientExpiryExtendedSchema,
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -107,6 +107,16 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z
|
|||||||
.boolean()
|
.boolean()
|
||||||
.describe('Whether to allow recipients to sign using an uploaded signature.');
|
.describe('Whether to allow recipients to sign using an uploaded signature.');
|
||||||
|
|
||||||
|
export const ZDocumentExpiryAmountSchema = z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.describe('The amount for expiry duration (e.g., 3 for "3 days").');
|
||||||
|
|
||||||
|
export const ZDocumentExpiryUnitSchema = z
|
||||||
|
.enum(['minutes', 'hours', 'days', 'weeks', 'months'])
|
||||||
|
.describe('The unit for expiry duration (e.g., "days" for "3 days").');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Note: Any updates to this will cause public API changes. You will need to update
|
* Note: Any updates to this will cause public API changes. You will need to update
|
||||||
* all corresponding areas where this is used (some places that use this needs to pass
|
* all corresponding areas where this is used (some places that use this needs to pass
|
||||||
@ -127,7 +137,9 @@ export const ZDocumentMetaCreateSchema = z.object({
|
|||||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||||
emailId: z.string().nullish(),
|
emailId: z.string().nullish(),
|
||||||
emailReplyTo: z.string().email().nullish(),
|
emailReplyTo: z.string().email().nullish(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||||
|
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
|
||||||
|
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
|
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
|
||||||
|
|||||||
@ -69,6 +69,8 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
|||||||
emailSettings: true,
|
emailSettings: true,
|
||||||
emailId: true,
|
emailId: true,
|
||||||
emailReplyTo: true,
|
emailReplyTo: true,
|
||||||
|
expiryAmount: true,
|
||||||
|
expiryUnit: true,
|
||||||
}).extend({
|
}).extend({
|
||||||
password: z.string().nullable().default(null),
|
password: z.string().nullable().default(null),
|
||||||
documentId: z.number().default(-1).optional(),
|
documentId: z.number().default(-1).optional(),
|
||||||
|
|||||||
@ -81,7 +81,6 @@ export const ZRadioFieldMeta = ZBaseFieldMeta.extend({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
direction: z.enum(['vertical', 'horizontal']).optional().default('vertical'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
|
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
|
||||||
@ -279,7 +278,6 @@ export const FIELD_RADIO_META_DEFAULT_VALUES: TRadioFieldMeta = {
|
|||||||
values: [{ id: 1, checked: false, value: '' }],
|
values: [{ id: 1, checked: false, value: '' }],
|
||||||
required: false,
|
required: false,
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
direction: 'vertical',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {
|
export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export const upsertFieldGroup = (
|
|||||||
field: FieldToRender,
|
field: FieldToRender,
|
||||||
options: RenderFieldElementOptions,
|
options: RenderFieldElementOptions,
|
||||||
): Konva.Group => {
|
): Konva.Group => {
|
||||||
const { pageWidth, pageHeight, pageLayer, editable, scale } = options;
|
const { pageWidth, pageHeight, pageLayer, editable } = options;
|
||||||
|
|
||||||
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
|
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
|
||||||
field,
|
field,
|
||||||
@ -27,9 +27,6 @@ export const upsertFieldGroup = (
|
|||||||
name: 'field-group',
|
name: 'field-group',
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxXPosition = (pageWidth - fieldWidth) * scale;
|
|
||||||
const maxYPosition = (pageHeight - fieldHeight) * scale;
|
|
||||||
|
|
||||||
fieldGroup.setAttrs({
|
fieldGroup.setAttrs({
|
||||||
scaleX: 1,
|
scaleX: 1,
|
||||||
scaleY: 1,
|
scaleY: 1,
|
||||||
@ -37,9 +34,8 @@ export const upsertFieldGroup = (
|
|||||||
y: fieldY,
|
y: fieldY,
|
||||||
draggable: editable,
|
draggable: editable,
|
||||||
dragBoundFunc: (pos) => {
|
dragBoundFunc: (pos) => {
|
||||||
const newX = Math.max(0, Math.min(maxXPosition, pos.x));
|
const newX = Math.max(0, Math.min(pageWidth - fieldWidth, pos.x));
|
||||||
const newY = Math.max(0, Math.min(maxYPosition, pos.y));
|
const newY = Math.max(0, Math.min(pageHeight - fieldHeight, pos.y));
|
||||||
|
|
||||||
return { x: newX, y: newY };
|
return { x: newX, y: newY };
|
||||||
},
|
},
|
||||||
} satisfies Partial<Konva.GroupConfig>);
|
} satisfies Partial<Konva.GroupConfig>);
|
||||||
|
|||||||
@ -26,9 +26,8 @@ export type RenderFieldElementOptions = {
|
|||||||
pageLayer: Konva.Layer;
|
pageLayer: Konva.Layer;
|
||||||
pageWidth: number;
|
pageWidth: number;
|
||||||
pageHeight: number;
|
pageHeight: number;
|
||||||
mode: 'edit' | 'sign' | 'export';
|
mode?: 'edit' | 'sign' | 'export';
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
scale: number;
|
|
||||||
color?: TRecipientColor;
|
color?: TRecipientColor;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -108,11 +107,6 @@ type CalculateMultiItemPositionOptions = {
|
|||||||
*/
|
*/
|
||||||
fieldPadding: number;
|
fieldPadding: number;
|
||||||
|
|
||||||
/**
|
|
||||||
* The direction of the items.
|
|
||||||
*/
|
|
||||||
direction: 'horizontal' | 'vertical';
|
|
||||||
|
|
||||||
type: 'checkbox' | 'radio';
|
type: 'checkbox' | 'radio';
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -128,7 +122,6 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
|
|||||||
itemSize,
|
itemSize,
|
||||||
spacingBetweenItemAndText,
|
spacingBetweenItemAndText,
|
||||||
fieldPadding,
|
fieldPadding,
|
||||||
direction,
|
|
||||||
type,
|
type,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@ -137,39 +130,6 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
|
|||||||
const innerFieldX = fieldPadding;
|
const innerFieldX = fieldPadding;
|
||||||
const innerFieldY = fieldPadding;
|
const innerFieldY = fieldPadding;
|
||||||
|
|
||||||
if (direction === 'horizontal') {
|
|
||||||
const itemHeight = innerFieldHeight;
|
|
||||||
const itemWidth = innerFieldWidth / itemCount;
|
|
||||||
|
|
||||||
const y = innerFieldY;
|
|
||||||
const x = itemIndex * itemWidth + innerFieldX;
|
|
||||||
|
|
||||||
let itemInputY = y + itemHeight / 2 - itemSize / 2;
|
|
||||||
let itemInputX = x;
|
|
||||||
|
|
||||||
// We need a little different logic to center the radio circle icon.
|
|
||||||
if (type === 'radio') {
|
|
||||||
itemInputX = x + itemSize / 2;
|
|
||||||
itemInputY = y + itemHeight / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const textX = x + itemSize + spacingBetweenItemAndText;
|
|
||||||
const textY = y;
|
|
||||||
|
|
||||||
// Multiplied by 2 for extra padding on the right hand side of the text and the next item.
|
|
||||||
const textWidth = itemWidth - itemSize - spacingBetweenItemAndText * 2;
|
|
||||||
const textHeight = itemHeight;
|
|
||||||
|
|
||||||
return {
|
|
||||||
itemInputX,
|
|
||||||
itemInputY,
|
|
||||||
textX,
|
|
||||||
textY,
|
|
||||||
textWidth,
|
|
||||||
textHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemHeight = innerFieldHeight / itemCount;
|
const itemHeight = innerFieldHeight / itemCount;
|
||||||
|
|
||||||
const y = itemIndex * itemHeight + innerFieldY;
|
const y = itemIndex * itemHeight + innerFieldY;
|
||||||
@ -177,7 +137,6 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
|
|||||||
let itemInputY = y + itemHeight / 2 - itemSize / 2;
|
let itemInputY = y + itemHeight / 2 - itemSize / 2;
|
||||||
let itemInputX = innerFieldX;
|
let itemInputX = innerFieldX;
|
||||||
|
|
||||||
// We need a little different logic to center the radio circle icon.
|
|
||||||
if (type === 'radio') {
|
if (type === 'radio') {
|
||||||
itemInputX = innerFieldX + itemSize / 2;
|
itemInputX = innerFieldX + itemSize / 2;
|
||||||
itemInputY = y + itemHeight / 2;
|
itemInputY = y + itemHeight / 2;
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import Konva from 'konva';
|
import Konva from 'konva';
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
|
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
|
||||||
import type { TCheckboxFieldMeta } from '../../types/field-meta';
|
import type { TCheckboxFieldMeta } from '../../types/field-meta';
|
||||||
@ -22,112 +21,104 @@ export const renderCheckboxFieldElement = (
|
|||||||
|
|
||||||
const fieldGroup = upsertFieldGroup(field, options);
|
const fieldGroup = upsertFieldGroup(field, options);
|
||||||
|
|
||||||
// Clear previous children and listeners to re-render fresh.
|
// Clear previous children to re-render fresh
|
||||||
fieldGroup.removeChildren();
|
fieldGroup.removeChildren();
|
||||||
fieldGroup.off('transform');
|
|
||||||
|
|
||||||
fieldGroup.add(upsertFieldRect(field, options));
|
fieldGroup.add(upsertFieldRect(field, options));
|
||||||
|
|
||||||
|
if (isFirstRender) {
|
||||||
|
pageLayer.add(fieldGroup);
|
||||||
|
|
||||||
|
// Handle rescaling items during transforms.
|
||||||
|
fieldGroup.on('transform', () => {
|
||||||
|
const groupScaleX = fieldGroup.scaleX();
|
||||||
|
const groupScaleY = fieldGroup.scaleY();
|
||||||
|
|
||||||
|
const fieldRect = fieldGroup.findOne('.field-rect');
|
||||||
|
|
||||||
|
if (!fieldRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rectWidth = fieldRect.width() * groupScaleX;
|
||||||
|
const rectHeight = fieldRect.height() * groupScaleY;
|
||||||
|
|
||||||
|
// Todo: Envelopes - check sorting more than 10
|
||||||
|
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||||
|
|
||||||
|
const squares = fieldGroup
|
||||||
|
.find('.checkbox-square')
|
||||||
|
.sort((a, b) => a.id().localeCompare(b.id()));
|
||||||
|
const checkmarks = fieldGroup
|
||||||
|
.find('.checkbox-checkmark')
|
||||||
|
.sort((a, b) => a.id().localeCompare(b.id()));
|
||||||
|
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
|
||||||
|
|
||||||
|
const groupedItems = squares.map((square, i) => ({
|
||||||
|
squareElement: square,
|
||||||
|
checkmarkElement: checkmarks[i],
|
||||||
|
textElement: text[i],
|
||||||
|
}));
|
||||||
|
|
||||||
|
groupedItems.forEach((item, i) => {
|
||||||
|
const { squareElement, checkmarkElement, textElement } = item;
|
||||||
|
|
||||||
|
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||||
|
calculateMultiItemPosition({
|
||||||
|
fieldWidth: rectWidth,
|
||||||
|
fieldHeight: rectHeight,
|
||||||
|
itemCount: checkboxValues.length,
|
||||||
|
itemIndex: i,
|
||||||
|
itemSize: checkboxSize,
|
||||||
|
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
||||||
|
fieldPadding: checkboxFieldPadding,
|
||||||
|
type: 'checkbox',
|
||||||
|
});
|
||||||
|
|
||||||
|
squareElement.setAttrs({
|
||||||
|
x: itemInputX,
|
||||||
|
y: itemInputY,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
checkmarkElement.setAttrs({
|
||||||
|
x: itemInputX,
|
||||||
|
y: itemInputY,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
textElement.setAttrs({
|
||||||
|
x: textX,
|
||||||
|
y: textY,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
width: textWidth,
|
||||||
|
height: textHeight,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fieldRect.setAttrs({
|
||||||
|
width: rectWidth,
|
||||||
|
height: rectHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
fieldGroup.scale({
|
||||||
|
x: 1,
|
||||||
|
y: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
pageLayer.batchDraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null;
|
const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null;
|
||||||
const checkboxValues = checkboxMeta?.values || [];
|
const checkboxValues = checkboxMeta?.values || [];
|
||||||
|
|
||||||
if (isFirstRender) {
|
|
||||||
pageLayer.add(fieldGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle rescaling items during transforms.
|
|
||||||
fieldGroup.on('transform', () => {
|
|
||||||
const groupScaleX = fieldGroup.scaleX();
|
|
||||||
const groupScaleY = fieldGroup.scaleY();
|
|
||||||
|
|
||||||
const fieldRect = fieldGroup.findOne('.field-rect');
|
|
||||||
|
|
||||||
if (!fieldRect) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rectWidth = fieldRect.width() * groupScaleX;
|
|
||||||
const rectHeight = fieldRect.height() * groupScaleY;
|
|
||||||
|
|
||||||
// Todo: Envelopes - check sorting more than 10
|
|
||||||
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
||||||
|
|
||||||
const squares = fieldGroup
|
|
||||||
.find('.checkbox-square')
|
|
||||||
.sort((a, b) => a.id().localeCompare(b.id()));
|
|
||||||
const checkmarks = fieldGroup
|
|
||||||
.find('.checkbox-checkmark')
|
|
||||||
.sort((a, b) => a.id().localeCompare(b.id()));
|
|
||||||
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
|
|
||||||
|
|
||||||
const groupedItems = squares.map((square, i) => ({
|
|
||||||
squareElement: square,
|
|
||||||
checkmarkElement: checkmarks[i],
|
|
||||||
textElement: text[i],
|
|
||||||
}));
|
|
||||||
|
|
||||||
groupedItems.forEach((item, i) => {
|
|
||||||
const { squareElement, checkmarkElement, textElement } = item;
|
|
||||||
|
|
||||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
|
||||||
calculateMultiItemPosition({
|
|
||||||
fieldWidth: rectWidth,
|
|
||||||
fieldHeight: rectHeight,
|
|
||||||
itemCount: checkboxValues.length,
|
|
||||||
itemIndex: i,
|
|
||||||
itemSize: checkboxSize,
|
|
||||||
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
|
||||||
fieldPadding: checkboxFieldPadding,
|
|
||||||
direction: checkboxMeta?.direction || 'vertical',
|
|
||||||
type: 'checkbox',
|
|
||||||
});
|
|
||||||
|
|
||||||
squareElement.setAttrs({
|
|
||||||
x: itemInputX,
|
|
||||||
y: itemInputY,
|
|
||||||
scaleX: 1,
|
|
||||||
scaleY: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
checkmarkElement.setAttrs({
|
|
||||||
x: itemInputX,
|
|
||||||
y: itemInputY,
|
|
||||||
scaleX: 1,
|
|
||||||
scaleY: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
textElement.setAttrs({
|
|
||||||
x: textX,
|
|
||||||
y: textY,
|
|
||||||
scaleX: 1,
|
|
||||||
scaleY: 1,
|
|
||||||
width: textWidth,
|
|
||||||
height: textHeight,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
fieldRect.setAttrs({
|
|
||||||
width: rectWidth,
|
|
||||||
height: rectHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
fieldGroup.scale({
|
|
||||||
x: 1,
|
|
||||||
y: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
pageLayer.batchDraw();
|
|
||||||
});
|
|
||||||
|
|
||||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||||
|
|
||||||
checkboxValues.forEach(({ id, value, checked }, index) => {
|
checkboxValues.forEach(({ id, value, checked }, index) => {
|
||||||
const isCheckboxChecked = match(mode)
|
|
||||||
.with('edit', () => checked)
|
|
||||||
.with('sign', () => value === field.customText)
|
|
||||||
.with('export', () => value === field.customText)
|
|
||||||
.exhaustive();
|
|
||||||
|
|
||||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||||
calculateMultiItemPosition({
|
calculateMultiItemPosition({
|
||||||
fieldWidth,
|
fieldWidth,
|
||||||
@ -137,7 +128,6 @@ export const renderCheckboxFieldElement = (
|
|||||||
itemSize: checkboxSize,
|
itemSize: checkboxSize,
|
||||||
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
||||||
fieldPadding: checkboxFieldPadding,
|
fieldPadding: checkboxFieldPadding,
|
||||||
direction: checkboxMeta?.direction || 'vertical',
|
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -166,7 +156,7 @@ export const renderCheckboxFieldElement = (
|
|||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
stroke: '#111827',
|
stroke: '#111827',
|
||||||
points: [3, 8, 7, 12, 13, 4],
|
points: [3, 8, 7, 12, 13, 4],
|
||||||
visible: isCheckboxChecked,
|
visible: checked,
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = new Konva.Text({
|
const text = new Konva.Text({
|
||||||
|
|||||||
@ -47,7 +47,6 @@ type RenderFieldOptions = {
|
|||||||
*/
|
*/
|
||||||
mode: 'edit' | 'sign' | 'export';
|
mode: 'edit' | 'sign' | 'export';
|
||||||
|
|
||||||
scale: number;
|
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,7 +56,6 @@ export const renderField = ({
|
|||||||
pageWidth,
|
pageWidth,
|
||||||
pageHeight,
|
pageHeight,
|
||||||
mode,
|
mode,
|
||||||
scale,
|
|
||||||
editable,
|
editable,
|
||||||
color,
|
color,
|
||||||
}: RenderFieldOptions) => {
|
}: RenderFieldOptions) => {
|
||||||
@ -68,7 +66,6 @@ export const renderField = ({
|
|||||||
mode,
|
mode,
|
||||||
color,
|
color,
|
||||||
editable,
|
editable,
|
||||||
scale,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return match(field.type)
|
return match(field.type)
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import Konva from 'konva';
|
import Konva from 'konva';
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
|
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
|
||||||
import type { TRadioFieldMeta } from '../../types/field-meta';
|
import type { TRadioFieldMeta } from '../../types/field-meta';
|
||||||
@ -27,99 +26,90 @@ export const renderRadioFieldElement = (
|
|||||||
|
|
||||||
fieldGroup.add(upsertFieldRect(field, options));
|
fieldGroup.add(upsertFieldRect(field, options));
|
||||||
|
|
||||||
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
|
|
||||||
const radioValues = radioMeta?.values || [];
|
|
||||||
|
|
||||||
if (isFirstRender) {
|
if (isFirstRender) {
|
||||||
pageLayer.add(fieldGroup);
|
pageLayer.add(fieldGroup);
|
||||||
}
|
|
||||||
|
|
||||||
fieldGroup.off('transform');
|
// Handle rescaling items during transforms.
|
||||||
|
fieldGroup.on('transform', () => {
|
||||||
|
const groupScaleX = fieldGroup.scaleX();
|
||||||
|
const groupScaleY = fieldGroup.scaleY();
|
||||||
|
|
||||||
// Handle rescaling items during transforms.
|
const fieldRect = fieldGroup.findOne('.field-rect');
|
||||||
fieldGroup.on('transform', () => {
|
|
||||||
const groupScaleX = fieldGroup.scaleX();
|
|
||||||
const groupScaleY = fieldGroup.scaleY();
|
|
||||||
|
|
||||||
const fieldRect = fieldGroup.findOne('.field-rect');
|
if (!fieldRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!fieldRect) {
|
const rectWidth = fieldRect.width() * groupScaleX;
|
||||||
return;
|
const rectHeight = fieldRect.height() * groupScaleY;
|
||||||
}
|
|
||||||
|
|
||||||
const rectWidth = fieldRect.width() * groupScaleX;
|
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
|
||||||
const rectHeight = fieldRect.height() * groupScaleY;
|
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
|
||||||
|
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
|
||||||
|
|
||||||
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
|
const groupedItems = circles.map((circle, i) => ({
|
||||||
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
|
circleElement: circle,
|
||||||
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
|
checkmarkElement: checkmarks[i],
|
||||||
|
textElement: text[i],
|
||||||
|
}));
|
||||||
|
|
||||||
const groupedItems = circles.map((circle, i) => ({
|
groupedItems.forEach((item, i) => {
|
||||||
circleElement: circle,
|
const { circleElement, checkmarkElement, textElement } = item;
|
||||||
checkmarkElement: checkmarks[i],
|
|
||||||
textElement: text[i],
|
|
||||||
}));
|
|
||||||
|
|
||||||
groupedItems.forEach((item, i) => {
|
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||||
const { circleElement, checkmarkElement, textElement } = item;
|
calculateMultiItemPosition({
|
||||||
|
fieldWidth: rectWidth,
|
||||||
|
fieldHeight: rectHeight,
|
||||||
|
itemCount: radioValues.length,
|
||||||
|
itemIndex: i,
|
||||||
|
itemSize: radioSize,
|
||||||
|
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
||||||
|
fieldPadding: radioFieldPadding,
|
||||||
|
type: 'radio',
|
||||||
|
});
|
||||||
|
|
||||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
circleElement.setAttrs({
|
||||||
calculateMultiItemPosition({
|
x: itemInputX,
|
||||||
fieldWidth: rectWidth,
|
y: itemInputY,
|
||||||
fieldHeight: rectHeight,
|
scaleX: 1,
|
||||||
itemCount: radioValues.length,
|
scaleY: 1,
|
||||||
itemIndex: i,
|
|
||||||
itemSize: radioSize,
|
|
||||||
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
|
||||||
fieldPadding: radioFieldPadding,
|
|
||||||
type: 'radio',
|
|
||||||
direction: radioMeta?.direction || 'vertical',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
circleElement.setAttrs({
|
checkmarkElement.setAttrs({
|
||||||
x: itemInputX,
|
x: itemInputX,
|
||||||
y: itemInputY,
|
y: itemInputY,
|
||||||
scaleX: 1,
|
scaleX: 1,
|
||||||
scaleY: 1,
|
scaleY: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
textElement.setAttrs({
|
||||||
|
x: textX,
|
||||||
|
y: textY,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
width: textWidth,
|
||||||
|
height: textHeight,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
checkmarkElement.setAttrs({
|
fieldRect.width(rectWidth);
|
||||||
x: itemInputX,
|
fieldRect.height(rectHeight);
|
||||||
y: itemInputY,
|
|
||||||
scaleX: 1,
|
fieldGroup.scale({
|
||||||
scaleY: 1,
|
x: 1,
|
||||||
|
y: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
textElement.setAttrs({
|
pageLayer.batchDraw();
|
||||||
x: textX,
|
|
||||||
y: textY,
|
|
||||||
scaleX: 1,
|
|
||||||
scaleY: 1,
|
|
||||||
width: textWidth,
|
|
||||||
height: textHeight,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fieldRect.width(rectWidth);
|
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
|
||||||
fieldRect.height(rectHeight);
|
const radioValues = radioMeta?.values || [];
|
||||||
|
|
||||||
fieldGroup.scale({
|
|
||||||
x: 1,
|
|
||||||
y: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
pageLayer.batchDraw();
|
|
||||||
});
|
|
||||||
|
|
||||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||||
|
|
||||||
radioValues.forEach(({ value, checked }, index) => {
|
radioValues.forEach(({ value, checked }, index) => {
|
||||||
const isRadioValueChecked = match(mode)
|
|
||||||
.with('edit', () => checked)
|
|
||||||
.with('sign', () => value === field.customText)
|
|
||||||
.with('export', () => value === field.customText)
|
|
||||||
.exhaustive();
|
|
||||||
|
|
||||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||||
calculateMultiItemPosition({
|
calculateMultiItemPosition({
|
||||||
fieldWidth,
|
fieldWidth,
|
||||||
@ -130,7 +120,6 @@ export const renderRadioFieldElement = (
|
|||||||
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
||||||
fieldPadding: radioFieldPadding,
|
fieldPadding: radioFieldPadding,
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
direction: radioMeta?.direction || 'vertical',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Circle which represents the radio button.
|
// Circle which represents the radio button.
|
||||||
@ -155,7 +144,9 @@ export const renderRadioFieldElement = (
|
|||||||
y: itemInputY,
|
y: itemInputY,
|
||||||
radius: radioSize / 4,
|
radius: radioSize / 4,
|
||||||
fill: '#111827',
|
fill: '#111827',
|
||||||
visible: isRadioValueChecked,
|
// Todo: Envelopes
|
||||||
|
visible: value === field.customText,
|
||||||
|
// visible: checked,
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = new Konva.Text({
|
const text = new Konva.Text({
|
||||||
|
|||||||
@ -96,80 +96,77 @@ export const renderSignatureFieldElement = (
|
|||||||
|
|
||||||
const fieldGroup = upsertFieldGroup(field, options);
|
const fieldGroup = upsertFieldGroup(field, options);
|
||||||
|
|
||||||
// Clear previous children and listeners to re-render fresh.
|
// ABOVE IS GENERIC, EXTRACT IT.
|
||||||
fieldGroup.removeChildren();
|
|
||||||
fieldGroup.off('transform');
|
|
||||||
|
|
||||||
// Assign elements to group and any listeners that should only be run on initialization.
|
|
||||||
if (isFirstRender) {
|
|
||||||
pageLayer.add(fieldGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the field background and text.
|
// Render the field background and text.
|
||||||
const fieldRect = upsertFieldRect(field, options);
|
const fieldRect = upsertFieldRect(field, options);
|
||||||
const fieldText = upsertFieldText(field, options);
|
const fieldText = upsertFieldText(field, options);
|
||||||
|
|
||||||
fieldGroup.add(fieldRect);
|
// Assign elements to group and any listeners that should only be run on initialization.
|
||||||
fieldGroup.add(fieldText);
|
if (isFirstRender) {
|
||||||
|
fieldGroup.add(fieldRect);
|
||||||
|
fieldGroup.add(fieldText);
|
||||||
|
pageLayer.add(fieldGroup);
|
||||||
|
|
||||||
// This is to keep the text inside the field at the same size
|
// This is to keep the text inside the field at the same size
|
||||||
// when the field is resized. Without this the text would be stretched.
|
// when the field is resized. Without this the text would be stretched.
|
||||||
fieldGroup.on('transform', () => {
|
fieldGroup.on('transform', () => {
|
||||||
const groupScaleX = fieldGroup.scaleX();
|
const groupScaleX = fieldGroup.scaleX();
|
||||||
const groupScaleY = fieldGroup.scaleY();
|
const groupScaleY = fieldGroup.scaleY();
|
||||||
|
|
||||||
// Adjust text scale so it doesn't change while group is resized.
|
// Adjust text scale so it doesn't change while group is resized.
|
||||||
fieldText.scaleX(1 / groupScaleX);
|
fieldText.scaleX(1 / groupScaleX);
|
||||||
fieldText.scaleY(1 / groupScaleY);
|
fieldText.scaleY(1 / groupScaleY);
|
||||||
|
|
||||||
const rectWidth = fieldRect.width() * groupScaleX;
|
const rectWidth = fieldRect.width() * groupScaleX;
|
||||||
const rectHeight = fieldRect.height() * groupScaleY;
|
const rectHeight = fieldRect.height() * groupScaleY;
|
||||||
|
|
||||||
// // Update text group position and clipping
|
// // Update text group position and clipping
|
||||||
// fieldGroup.clipFunc(function (ctx) {
|
// fieldGroup.clipFunc(function (ctx) {
|
||||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// Update text dimensions
|
// Update text dimensions
|
||||||
fieldText.width(rectWidth); // Account for padding
|
fieldText.width(rectWidth); // Account for padding
|
||||||
fieldText.height(rectHeight);
|
fieldText.height(rectHeight);
|
||||||
|
|
||||||
console.log({
|
console.log({
|
||||||
rectWidth,
|
rectWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force Konva to recalculate text layout
|
||||||
|
// textInsideField.getTextHeight(); // This forces recalculation
|
||||||
|
fieldText.height(); // This forces recalculation
|
||||||
|
|
||||||
|
// fieldGroup.draw();
|
||||||
|
fieldGroup.getLayer()?.batchDraw();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Force Konva to recalculate text layout
|
// Reset the text after transform has ended.
|
||||||
// textInsideField.getTextHeight(); // This forces recalculation
|
fieldGroup.on('transformend', () => {
|
||||||
fieldText.height(); // This forces recalculation
|
fieldText.scaleX(1);
|
||||||
|
fieldText.scaleY(1);
|
||||||
|
|
||||||
// fieldGroup.draw();
|
const rectWidth = fieldRect.width();
|
||||||
fieldGroup.getLayer()?.batchDraw();
|
const rectHeight = fieldRect.height();
|
||||||
});
|
|
||||||
|
|
||||||
// Reset the text after transform has ended.
|
// // Update text group position and clipping
|
||||||
fieldGroup.on('transformend', () => {
|
// fieldGroup.clipFunc(function (ctx) {
|
||||||
fieldText.scaleX(1);
|
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||||
fieldText.scaleY(1);
|
// });
|
||||||
|
|
||||||
const rectWidth = fieldRect.width();
|
// Update text dimensions
|
||||||
const rectHeight = fieldRect.height();
|
fieldText.width(rectWidth); // Account for padding
|
||||||
|
fieldText.height(rectHeight);
|
||||||
|
|
||||||
// // Update text group position and clipping
|
// Force Konva to recalculate text layout
|
||||||
// fieldGroup.clipFunc(function (ctx) {
|
// textInsideField.getTextHeight(); // This forces recalculation
|
||||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
fieldText.height(); // This forces recalculation
|
||||||
// });
|
|
||||||
|
|
||||||
// Update text dimensions
|
// fieldGroup.draw();
|
||||||
fieldText.width(rectWidth); // Account for padding
|
fieldGroup.getLayer()?.batchDraw();
|
||||||
fieldText.height(rectHeight);
|
});
|
||||||
|
}
|
||||||
// Force Konva to recalculate text layout
|
|
||||||
// textInsideField.getTextHeight(); // This forces recalculation
|
|
||||||
fieldText.height(); // This forces recalculation
|
|
||||||
|
|
||||||
// fieldGroup.draw();
|
|
||||||
fieldGroup.getLayer()?.batchDraw();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle export mode.
|
// Handle export mode.
|
||||||
if (mode === 'export') {
|
if (mode === 'export') {
|
||||||
|
|||||||
@ -121,80 +121,77 @@ export const renderTextFieldElement = (
|
|||||||
|
|
||||||
const fieldGroup = upsertFieldGroup(field, options);
|
const fieldGroup = upsertFieldGroup(field, options);
|
||||||
|
|
||||||
// Clear previous children and listeners to re-render fresh.
|
// ABOVE IS GENERIC, EXTRACT IT.
|
||||||
fieldGroup.removeChildren();
|
|
||||||
fieldGroup.off('transform');
|
|
||||||
|
|
||||||
// Assign elements to group and any listeners that should only be run on initialization.
|
|
||||||
if (isFirstRender) {
|
|
||||||
pageLayer.add(fieldGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the field background and text.
|
// Render the field background and text.
|
||||||
const fieldRect = upsertFieldRect(field, options);
|
const fieldRect = upsertFieldRect(field, options);
|
||||||
const fieldText = upsertFieldText(field, options);
|
const fieldText = upsertFieldText(field, options);
|
||||||
|
|
||||||
fieldGroup.add(fieldRect);
|
// Assign elements to group and any listeners that should only be run on initialization.
|
||||||
fieldGroup.add(fieldText);
|
if (isFirstRender) {
|
||||||
|
fieldGroup.add(fieldRect);
|
||||||
|
fieldGroup.add(fieldText);
|
||||||
|
pageLayer.add(fieldGroup);
|
||||||
|
|
||||||
// This is to keep the text inside the field at the same size
|
// This is to keep the text inside the field at the same size
|
||||||
// when the field is resized. Without this the text would be stretched.
|
// when the field is resized. Without this the text would be stretched.
|
||||||
fieldGroup.on('transform', () => {
|
fieldGroup.on('transform', () => {
|
||||||
const groupScaleX = fieldGroup.scaleX();
|
const groupScaleX = fieldGroup.scaleX();
|
||||||
const groupScaleY = fieldGroup.scaleY();
|
const groupScaleY = fieldGroup.scaleY();
|
||||||
|
|
||||||
// Adjust text scale so it doesn't change while group is resized.
|
// Adjust text scale so it doesn't change while group is resized.
|
||||||
fieldText.scaleX(1 / groupScaleX);
|
fieldText.scaleX(1 / groupScaleX);
|
||||||
fieldText.scaleY(1 / groupScaleY);
|
fieldText.scaleY(1 / groupScaleY);
|
||||||
|
|
||||||
const rectWidth = fieldRect.width() * groupScaleX;
|
const rectWidth = fieldRect.width() * groupScaleX;
|
||||||
const rectHeight = fieldRect.height() * groupScaleY;
|
const rectHeight = fieldRect.height() * groupScaleY;
|
||||||
|
|
||||||
// // Update text group position and clipping
|
// // Update text group position and clipping
|
||||||
// fieldGroup.clipFunc(function (ctx) {
|
// fieldGroup.clipFunc(function (ctx) {
|
||||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// Update text dimensions
|
// Update text dimensions
|
||||||
fieldText.width(rectWidth); // Account for padding
|
fieldText.width(rectWidth); // Account for padding
|
||||||
fieldText.height(rectHeight);
|
fieldText.height(rectHeight);
|
||||||
|
|
||||||
console.log({
|
console.log({
|
||||||
rectWidth,
|
rectWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force Konva to recalculate text layout
|
||||||
|
// textInsideField.getTextHeight(); // This forces recalculation
|
||||||
|
fieldText.height(); // This forces recalculation
|
||||||
|
|
||||||
|
// fieldGroup.draw();
|
||||||
|
fieldGroup.getLayer()?.batchDraw();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Force Konva to recalculate text layout
|
// Reset the text after transform has ended.
|
||||||
// textInsideField.getTextHeight(); // This forces recalculation
|
fieldGroup.on('transformend', () => {
|
||||||
fieldText.height(); // This forces recalculation
|
fieldText.scaleX(1);
|
||||||
|
fieldText.scaleY(1);
|
||||||
|
|
||||||
// fieldGroup.draw();
|
const rectWidth = fieldRect.width();
|
||||||
fieldGroup.getLayer()?.batchDraw();
|
const rectHeight = fieldRect.height();
|
||||||
});
|
|
||||||
|
|
||||||
// Reset the text after transform has ended.
|
// // Update text group position and clipping
|
||||||
fieldGroup.on('transformend', () => {
|
// fieldGroup.clipFunc(function (ctx) {
|
||||||
fieldText.scaleX(1);
|
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||||
fieldText.scaleY(1);
|
// });
|
||||||
|
|
||||||
const rectWidth = fieldRect.width();
|
// Update text dimensions
|
||||||
const rectHeight = fieldRect.height();
|
fieldText.width(rectWidth); // Account for padding
|
||||||
|
fieldText.height(rectHeight);
|
||||||
|
|
||||||
// // Update text group position and clipping
|
// Force Konva to recalculate text layout
|
||||||
// fieldGroup.clipFunc(function (ctx) {
|
// textInsideField.getTextHeight(); // This forces recalculation
|
||||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
fieldText.height(); // This forces recalculation
|
||||||
// });
|
|
||||||
|
|
||||||
// Update text dimensions
|
// fieldGroup.draw();
|
||||||
fieldText.width(rectWidth); // Account for padding
|
fieldGroup.getLayer()?.batchDraw();
|
||||||
fieldText.height(rectHeight);
|
});
|
||||||
|
}
|
||||||
// Force Konva to recalculate text layout
|
|
||||||
// textInsideField.getTextHeight(); // This forces recalculation
|
|
||||||
fieldText.height(); // This forces recalculation
|
|
||||||
|
|
||||||
// fieldGroup.draw();
|
|
||||||
fieldGroup.getLayer()?.batchDraw();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle export mode.
|
// Handle export mode.
|
||||||
if (mode === 'export') {
|
if (mode === 'export') {
|
||||||
|
|||||||
@ -515,6 +515,10 @@ export const formatDocumentAuditLogAction = (
|
|||||||
context: `Audit log format`,
|
context: `Audit log format`,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED }, (data) => ({
|
||||||
|
anonymous: msg`Recipient expiry extended`,
|
||||||
|
identified: msg`${prefix} extended expiry for ${data.data.recipientEmail}`,
|
||||||
|
}))
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -20,6 +20,26 @@ export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | Documen
|
|||||||
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
|
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getExpiryAmount = (meta: Partial<DocumentMeta> | undefined | null): number | null => {
|
||||||
|
if (!meta) return null;
|
||||||
|
|
||||||
|
if ('expiryAmount' in meta && meta.expiryAmount !== undefined) {
|
||||||
|
return meta.expiryAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExpiryUnit = (meta: Partial<DocumentMeta> | undefined | null): string | null => {
|
||||||
|
if (!meta) return null;
|
||||||
|
|
||||||
|
if ('expiryUnit' in meta && meta.expiryUnit !== undefined) {
|
||||||
|
return meta.expiryUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the derived document meta which should be used when creating a document
|
* Extracts the derived document meta which should be used when creating a document
|
||||||
* from scratch, or from a template.
|
* from scratch, or from a template.
|
||||||
@ -62,6 +82,10 @@ export const extractDerivedDocumentMeta = (
|
|||||||
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
|
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
|
||||||
emailSettings:
|
emailSettings:
|
||||||
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||||
|
|
||||||
|
// Expiry settings.
|
||||||
|
expiryAmount: getExpiryAmount(meta),
|
||||||
|
expiryUnit: getExpiryUnit(meta),
|
||||||
} satisfies Omit<DocumentMeta, 'id'>;
|
} satisfies Omit<DocumentMeta, 'id'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
72
packages/lib/utils/expiry.ts
Normal file
72
packages/lib/utils/expiry.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import type { Recipient } from '@prisma/client';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
export interface DurationValue {
|
||||||
|
amount: number;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateRecipientExpiry = (
|
||||||
|
documentExpiryAmount?: number | null,
|
||||||
|
documentExpiryUnit?: string | null,
|
||||||
|
fromDate: Date = new Date(),
|
||||||
|
): Date | null => {
|
||||||
|
if (!documentExpiryAmount || !documentExpiryUnit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (documentExpiryUnit) {
|
||||||
|
case 'minutes':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ minutes: documentExpiryAmount }).toJSDate();
|
||||||
|
case 'hours':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ hours: documentExpiryAmount }).toJSDate();
|
||||||
|
case 'days':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ days: documentExpiryAmount }).toJSDate();
|
||||||
|
case 'weeks':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ weeks: documentExpiryAmount }).toJSDate();
|
||||||
|
case 'months':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ months: documentExpiryAmount }).toJSDate();
|
||||||
|
default:
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ days: documentExpiryAmount }).toJSDate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isRecipientExpired = (recipient: Recipient): boolean => {
|
||||||
|
if (!recipient.expired) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.now() > DateTime.fromJSDate(recipient.expired);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isValidExpirySettings = (
|
||||||
|
expiryAmount?: number | null,
|
||||||
|
expiryUnit?: string | null,
|
||||||
|
): boolean => {
|
||||||
|
if (!expiryAmount || !expiryUnit) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return expiryAmount > 0 && ['minutes', 'hours', 'days', 'weeks', 'months'].includes(expiryUnit);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateExpiryDate = (duration: DurationValue, fromDate: Date = new Date()): Date => {
|
||||||
|
switch (duration.unit) {
|
||||||
|
case 'minutes':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ minutes: duration.amount }).toJSDate();
|
||||||
|
case 'hours':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ hours: duration.amount }).toJSDate();
|
||||||
|
case 'days':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate();
|
||||||
|
case 'weeks':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ weeks: duration.amount }).toJSDate();
|
||||||
|
case 'months':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ months: duration.amount }).toJSDate();
|
||||||
|
default:
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatExpiryDate = (date: Date): string => {
|
||||||
|
return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy HH:mm');
|
||||||
|
};
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "SigningStatus" ADD VALUE 'EXPIRED';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DocumentMeta" ADD COLUMN "expiryAmount" INTEGER,
|
||||||
|
ADD COLUMN "expiryUnit" TEXT;
|
||||||
@ -505,6 +505,9 @@ model DocumentMeta {
|
|||||||
emailReplyTo String?
|
emailReplyTo String?
|
||||||
emailId String?
|
emailId String?
|
||||||
|
|
||||||
|
expiryAmount Int?
|
||||||
|
expiryUnit String?
|
||||||
|
|
||||||
envelope Envelope?
|
envelope Envelope?
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -522,6 +525,7 @@ enum SigningStatus {
|
|||||||
NOT_SIGNED
|
NOT_SIGNED
|
||||||
SIGNED
|
SIGNED
|
||||||
REJECTED
|
REJECTED
|
||||||
|
EXPIRED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RecipientRole {
|
enum RecipientRole {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { createDocumentData } from '@documenso/lib/server-only/document-data/cre
|
|||||||
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
|
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
|
||||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
|
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
@ -37,8 +38,17 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
|
|||||||
recipients,
|
recipients,
|
||||||
meta,
|
meta,
|
||||||
folderId,
|
folderId,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
} = input;
|
} = input;
|
||||||
|
|
||||||
|
// Validate expiry settings
|
||||||
|
if ((expiryAmount || expiryUnit) && !isValidExpirySettings(expiryAmount, expiryUnit)) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Invalid expiry settings. Please check your expiry configuration.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { remaining } = await getServerLimits({ userId: user.id, teamId });
|
const { remaining } = await getServerLimits({ userId: user.id, teamId });
|
||||||
|
|
||||||
if (remaining.documents <= 0) {
|
if (remaining.documents <= 0) {
|
||||||
@ -86,7 +96,12 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
meta,
|
meta: {
|
||||||
|
...meta,
|
||||||
|
emailSettings: meta?.emailSettings ?? undefined,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
|
},
|
||||||
requestMetadata: ctx.metadata,
|
requestMetadata: ctx.metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,8 @@ import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
|||||||
import { ZCreateRecipientSchema } from '../recipient-router/schema';
|
import { ZCreateRecipientSchema } from '../recipient-router/schema';
|
||||||
import type { TrpcRouteMeta } from '../trpc';
|
import type { TrpcRouteMeta } from '../trpc';
|
||||||
import {
|
import {
|
||||||
|
ZDocumentExpiryAmountSchema,
|
||||||
|
ZDocumentExpiryUnitSchema,
|
||||||
ZDocumentExternalIdSchema,
|
ZDocumentExternalIdSchema,
|
||||||
ZDocumentTitleSchema,
|
ZDocumentTitleSchema,
|
||||||
ZDocumentVisibilitySchema,
|
ZDocumentVisibilitySchema,
|
||||||
@ -51,6 +53,8 @@ export const ZCreateDocumentTemporaryRequestSchema = z.object({
|
|||||||
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
|
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
|
||||||
|
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
|
||||||
recipients: z
|
recipients: z
|
||||||
.array(
|
.array(
|
||||||
ZCreateRecipientSchema.extend({
|
ZCreateRecipientSchema.extend({
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
|
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
|
||||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
|
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@ -16,7 +17,14 @@ export const createDocumentRoute = authenticatedProcedure
|
|||||||
.output(ZCreateDocumentResponseSchema)
|
.output(ZCreateDocumentResponseSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { user, teamId } = ctx;
|
const { user, teamId } = ctx;
|
||||||
const { title, documentDataId, timezone, folderId } = input;
|
const { title, documentDataId, timezone, folderId, expiryAmount, expiryUnit } = input;
|
||||||
|
|
||||||
|
// Validate expiry settings
|
||||||
|
if ((expiryAmount || expiryUnit) && !isValidExpirySettings(expiryAmount, expiryUnit)) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Invalid expiry settings. Please check your expiry configuration.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ctx.logger.info({
|
ctx.logger.info({
|
||||||
input: {
|
input: {
|
||||||
@ -48,6 +56,10 @@ export const createDocumentRoute = authenticatedProcedure
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
meta: {
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
|
},
|
||||||
normalizePdf: true,
|
normalizePdf: true,
|
||||||
requestMetadata: ctx.metadata,
|
requestMetadata: ctx.metadata,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
|
import {
|
||||||
|
ZDocumentExpiryAmountSchema,
|
||||||
|
ZDocumentExpiryUnitSchema,
|
||||||
|
ZDocumentMetaTimezoneSchema,
|
||||||
|
} from '@documenso/lib/types/document-meta';
|
||||||
|
|
||||||
import { ZDocumentTitleSchema } from './schema';
|
import { ZDocumentTitleSchema } from './schema';
|
||||||
|
|
||||||
@ -19,6 +23,8 @@ export const ZCreateDocumentRequestSchema = z.object({
|
|||||||
documentDataId: z.string().min(1),
|
documentDataId: z.string().min(1),
|
||||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||||
folderId: z.string().describe('The ID of the folder to create the document in').optional(),
|
folderId: z.string().describe('The ID of the folder to create the document in').optional(),
|
||||||
|
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
|
||||||
|
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZCreateDocumentResponseSchema = z.object({
|
export const ZCreateDocumentResponseSchema = z.object({
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export const distributeDocumentRoute = authenticatedProcedure
|
|||||||
timezone: meta.timezone,
|
timezone: meta.timezone,
|
||||||
redirectUrl: meta.redirectUrl,
|
redirectUrl: meta.redirectUrl,
|
||||||
distributionMethod: meta.distributionMethod,
|
distributionMethod: meta.distributionMethod,
|
||||||
emailSettings: meta.emailSettings,
|
emailSettings: meta.emailSettings ?? undefined,
|
||||||
language: meta.language,
|
language: meta.language,
|
||||||
emailId: meta.emailId,
|
emailId: meta.emailId,
|
||||||
emailReplyTo: meta.emailReplyTo,
|
emailReplyTo: meta.emailReplyTo,
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { DocumentVisibility } from '@prisma/client';
|
import { DocumentVisibility } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ZDocumentExpiryAmountSchema,
|
||||||
|
ZDocumentExpiryUnitSchema,
|
||||||
|
} from '@documenso/lib/types/document-meta';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Required for empty responses since we currently can't 201 requests for our openapi setup.
|
* Required for empty responses since we currently can't 201 requests for our openapi setup.
|
||||||
*
|
*
|
||||||
@ -30,3 +35,6 @@ export const ZDocumentExternalIdSchema = z
|
|||||||
export const ZDocumentVisibilitySchema = z
|
export const ZDocumentVisibilitySchema = z
|
||||||
.nativeEnum(DocumentVisibility)
|
.nativeEnum(DocumentVisibility)
|
||||||
.describe('The visibility of the document.');
|
.describe('The visibility of the document.');
|
||||||
|
|
||||||
|
// Re-export expiry schemas for convenience
|
||||||
|
export { ZDocumentExpiryAmountSchema, ZDocumentExpiryUnitSchema };
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope';
|
import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope';
|
||||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
|
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@ -27,6 +29,15 @@ export const updateDocumentRoute = authenticatedProcedure
|
|||||||
|
|
||||||
const userId = ctx.user.id;
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(meta.expiryAmount || meta.expiryUnit) &&
|
||||||
|
!isValidExpirySettings(meta.expiryAmount, meta.expiryUnit)
|
||||||
|
) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Invalid expiry settings. Please check your expiry configuration.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const envelope = await updateEnvelope({
|
const envelope = await updateEnvelope({
|
||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export const distributeEnvelopeRoute = authenticatedProcedure
|
|||||||
timezone: meta.timezone,
|
timezone: meta.timezone,
|
||||||
redirectUrl: meta.redirectUrl,
|
redirectUrl: meta.redirectUrl,
|
||||||
distributionMethod: meta.distributionMethod,
|
distributionMethod: meta.distributionMethod,
|
||||||
emailSettings: meta.emailSettings,
|
emailSettings: meta.emailSettings ?? undefined,
|
||||||
language: meta.language,
|
language: meta.language,
|
||||||
emailId: meta.emailId,
|
emailId: meta.emailId,
|
||||||
emailReplyTo: meta.emailReplyTo,
|
emailReplyTo: meta.emailReplyTo,
|
||||||
|
|||||||
@ -91,11 +91,11 @@ export const PdfViewerKonva = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={$el} className={cn('w-full max-w-[800px]', className)} {...props}>
|
<div ref={$el} className={cn('w-[800px] overflow-hidden', className)} {...props}>
|
||||||
{envelopeItemFile && Konva ? (
|
{envelopeItemFile && Konva ? (
|
||||||
<PDFDocument
|
<PDFDocument
|
||||||
file={envelopeItemFile}
|
file={envelopeItemFile}
|
||||||
className={cn('w-full rounded', {
|
className={cn('w-full overflow-hidden rounded', {
|
||||||
'h-[80vh] max-h-[60rem]': numPages === 0,
|
'h-[80vh] max-h-[60rem]': numPages === 0,
|
||||||
})}
|
})}
|
||||||
onLoadSuccess={(d) => onDocumentLoaded(d)}
|
onLoadSuccess={(d) => onDocumentLoaded(d)}
|
||||||
@ -138,7 +138,7 @@ export const PdfViewerKonva = ({
|
|||||||
.fill(null)
|
.fill(null)
|
||||||
.map((_, i) => (
|
.map((_, i) => (
|
||||||
<div key={i} className="last:-mb-2">
|
<div key={i} className="last:-mb-2">
|
||||||
<div className="border-border rounded border will-change-transform">
|
<div className="border-border overflow-hidden rounded border will-change-transform">
|
||||||
<PDFPage
|
<PDFPage
|
||||||
pageNumber={i + 1}
|
pageNumber={i + 1}
|
||||||
width={width}
|
width={width}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ export type RecipientColorStyles = {
|
|||||||
base: string;
|
base: string;
|
||||||
baseRing: string;
|
baseRing: string;
|
||||||
baseRingHover: string;
|
baseRingHover: string;
|
||||||
fieldButton: string;
|
|
||||||
fieldItem: string;
|
fieldItem: string;
|
||||||
fieldItemInitials: string;
|
fieldItemInitials: string;
|
||||||
comboxBoxTrigger: string;
|
comboxBoxTrigger: string;
|
||||||
@ -24,7 +23,6 @@ export const RECIPIENT_COLOR_STYLES = {
|
|||||||
base: 'ring-neutral-400',
|
base: 'ring-neutral-400',
|
||||||
baseRing: 'rgba(176, 176, 176, 1)',
|
baseRing: 'rgba(176, 176, 176, 1)',
|
||||||
baseRingHover: 'rgba(176, 176, 176, 1)',
|
baseRingHover: 'rgba(176, 176, 176, 1)',
|
||||||
fieldButton: 'border-neutral-400 hover:border-neutral-400',
|
|
||||||
fieldItem: 'group/field-item rounded-[2px]',
|
fieldItem: 'group/field-item rounded-[2px]',
|
||||||
fieldItemInitials: '',
|
fieldItemInitials: '',
|
||||||
comboxBoxTrigger:
|
comboxBoxTrigger:
|
||||||
@ -36,7 +34,6 @@ export const RECIPIENT_COLOR_STYLES = {
|
|||||||
base: 'ring-recipient-green hover:bg-recipient-green/30',
|
base: 'ring-recipient-green hover:bg-recipient-green/30',
|
||||||
baseRing: 'rgba(122, 195, 85, 1)',
|
baseRing: 'rgba(122, 195, 85, 1)',
|
||||||
baseRingHover: 'rgba(122, 195, 85, 0.3)',
|
baseRingHover: 'rgba(122, 195, 85, 0.3)',
|
||||||
fieldButton: 'hover:border-recipient-green hover:bg-recipient-green/30 ',
|
|
||||||
fieldItem: 'group/field-item rounded-[2px]',
|
fieldItem: 'group/field-item rounded-[2px]',
|
||||||
fieldItemInitials: 'group-hover/field-item:bg-recipient-green',
|
fieldItemInitials: 'group-hover/field-item:bg-recipient-green',
|
||||||
comboxBoxTrigger:
|
comboxBoxTrigger:
|
||||||
@ -48,7 +45,6 @@ export const RECIPIENT_COLOR_STYLES = {
|
|||||||
base: 'ring-recipient-blue hover:bg-recipient-blue/30',
|
base: 'ring-recipient-blue hover:bg-recipient-blue/30',
|
||||||
baseRing: 'rgba(56, 123, 199, 1)',
|
baseRing: 'rgba(56, 123, 199, 1)',
|
||||||
baseRingHover: 'rgba(56, 123, 199, 0.3)',
|
baseRingHover: 'rgba(56, 123, 199, 0.3)',
|
||||||
fieldButton: 'hover:border-recipient-blue hover:bg-recipient-blue/30',
|
|
||||||
fieldItem: 'group/field-item rounded-[2px]',
|
fieldItem: 'group/field-item rounded-[2px]',
|
||||||
fieldItemInitials: 'group-hover/field-item:bg-recipient-blue',
|
fieldItemInitials: 'group-hover/field-item:bg-recipient-blue',
|
||||||
comboxBoxTrigger:
|
comboxBoxTrigger:
|
||||||
@ -60,7 +56,6 @@ export const RECIPIENT_COLOR_STYLES = {
|
|||||||
base: 'ring-recipient-purple hover:bg-recipient-purple/30',
|
base: 'ring-recipient-purple hover:bg-recipient-purple/30',
|
||||||
baseRing: 'rgba(151, 71, 255, 1)',
|
baseRing: 'rgba(151, 71, 255, 1)',
|
||||||
baseRingHover: 'rgba(151, 71, 255, 0.3)',
|
baseRingHover: 'rgba(151, 71, 255, 0.3)',
|
||||||
fieldButton: 'hover:border-recipient-purple hover:bg-recipient-purple/30',
|
|
||||||
fieldItem: 'group/field-item rounded-[2px]',
|
fieldItem: 'group/field-item rounded-[2px]',
|
||||||
fieldItemInitials: 'group-hover/field-item:bg-recipient-purple',
|
fieldItemInitials: 'group-hover/field-item:bg-recipient-purple',
|
||||||
comboxBoxTrigger:
|
comboxBoxTrigger:
|
||||||
@ -72,7 +67,6 @@ export const RECIPIENT_COLOR_STYLES = {
|
|||||||
base: 'ring-recipient-orange hover:bg-recipient-orange/30',
|
base: 'ring-recipient-orange hover:bg-recipient-orange/30',
|
||||||
baseRing: 'rgba(246, 159, 30, 1)',
|
baseRing: 'rgba(246, 159, 30, 1)',
|
||||||
baseRingHover: 'rgba(246, 159, 30, 0.3)',
|
baseRingHover: 'rgba(246, 159, 30, 0.3)',
|
||||||
fieldButton: 'hover:border-recipient-orange hover:bg-recipient-orange/30',
|
|
||||||
fieldItem: 'group/field-item rounded-[2px]',
|
fieldItem: 'group/field-item rounded-[2px]',
|
||||||
fieldItemInitials: 'group-hover/field-item:bg-recipient-orange',
|
fieldItemInitials: 'group-hover/field-item:bg-recipient-orange',
|
||||||
comboxBoxTrigger:
|
comboxBoxTrigger:
|
||||||
@ -84,7 +78,6 @@ export const RECIPIENT_COLOR_STYLES = {
|
|||||||
base: 'ring-recipient-yellow hover:bg-recipient-yellow/30',
|
base: 'ring-recipient-yellow hover:bg-recipient-yellow/30',
|
||||||
baseRing: 'rgba(219, 186, 0, 1)',
|
baseRing: 'rgba(219, 186, 0, 1)',
|
||||||
baseRingHover: 'rgba(219, 186, 0, 0.3)',
|
baseRingHover: 'rgba(219, 186, 0, 0.3)',
|
||||||
fieldButton: 'hover:border-recipient-yellow hover:bg-recipient-yellow/30',
|
|
||||||
fieldItem: 'group/field-item rounded-[2px]',
|
fieldItem: 'group/field-item rounded-[2px]',
|
||||||
fieldItemInitials: 'group-hover/field-item:bg-recipient-yellow',
|
fieldItemInitials: 'group-hover/field-item:bg-recipient-yellow',
|
||||||
comboxBoxTrigger:
|
comboxBoxTrigger:
|
||||||
@ -96,7 +89,6 @@ export const RECIPIENT_COLOR_STYLES = {
|
|||||||
base: 'ring-recipient-pink hover:bg-recipient-pink/30',
|
base: 'ring-recipient-pink hover:bg-recipient-pink/30',
|
||||||
baseRing: 'rgba(217, 74, 186, 1)',
|
baseRing: 'rgba(217, 74, 186, 1)',
|
||||||
baseRingHover: 'rgba(217, 74, 186, 0.3)',
|
baseRingHover: 'rgba(217, 74, 186, 0.3)',
|
||||||
fieldButton: 'hover:border-recipient-pink hover:bg-recipient-pink/30',
|
|
||||||
fieldItem: 'group/field-item rounded-[2px]',
|
fieldItem: 'group/field-item rounded-[2px]',
|
||||||
fieldItemInitials: 'group-hover/field-item:bg-recipient-pink',
|
fieldItemInitials: 'group-hover/field-item:bg-recipient-pink',
|
||||||
comboxBoxTrigger:
|
comboxBoxTrigger:
|
||||||
|
|||||||
131
packages/ui/primitives/date-time-picker.tsx
Normal file
131
packages/ui/primitives/date-time-picker.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { CalendarIcon } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { Button } from './button';
|
||||||
|
import { Calendar } from './calendar';
|
||||||
|
import { Input } from './input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||||
|
|
||||||
|
export interface DateTimePickerProps {
|
||||||
|
value?: Date;
|
||||||
|
onChange?: (date: Date | undefined) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
minDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateTimePicker = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
minDate = new Date(),
|
||||||
|
}: DateTimePickerProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const handleDateSelect = (selectedDate: Date | undefined) => {
|
||||||
|
if (!selectedDate) {
|
||||||
|
onChange?.(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const existingTime = DateTime.fromJSDate(value);
|
||||||
|
const newDateTime = DateTime.fromJSDate(selectedDate).set({
|
||||||
|
hour: existingTime.hour,
|
||||||
|
minute: existingTime.minute,
|
||||||
|
});
|
||||||
|
onChange?.(newDateTime.toJSDate());
|
||||||
|
} else {
|
||||||
|
const now = DateTime.now();
|
||||||
|
const newDateTime = DateTime.fromJSDate(selectedDate).set({
|
||||||
|
hour: now.hour,
|
||||||
|
minute: now.minute,
|
||||||
|
});
|
||||||
|
onChange?.(newDateTime.toJSDate());
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const timeValue = event.target.value;
|
||||||
|
if (!timeValue || !value) return;
|
||||||
|
|
||||||
|
const [hours, minutes] = timeValue.split(':').map(Number);
|
||||||
|
const newDateTime = DateTime.fromJSDate(value).set({
|
||||||
|
hour: hours,
|
||||||
|
minute: minutes,
|
||||||
|
});
|
||||||
|
|
||||||
|
onChange?.(newDateTime.toJSDate());
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (date: Date) => {
|
||||||
|
return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
return DateTime.fromJSDate(date).toFormat('HH:mm');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex gap-2', className)}>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'w-[200px] justify-start text-left font-normal',
|
||||||
|
!value && 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{value ? formatDateTime(value) : <span>{placeholder || _(msg`Pick a date`)}</span>}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={value}
|
||||||
|
onSelect={handleDateSelect}
|
||||||
|
disabled={
|
||||||
|
disabled
|
||||||
|
? true
|
||||||
|
: (date) => {
|
||||||
|
return date < minDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialFocus
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{value && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
<Trans>at</Trans>
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={formatTime(value)}
|
||||||
|
onChange={handleTimeChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-[120px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -11,7 +11,7 @@ import {
|
|||||||
TeamMemberRole,
|
TeamMemberRole,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { InfoIcon } from 'lucide-react';
|
import { InfoIcon } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||||
@ -57,6 +57,7 @@ import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combo
|
|||||||
|
|
||||||
import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip';
|
import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip';
|
||||||
import { Combobox } from '../combobox';
|
import { Combobox } from '../combobox';
|
||||||
|
import { ExpirySettingsPicker } from '../expiry-settings-picker';
|
||||||
import { Input } from '../input';
|
import { Input } from '../input';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
|
||||||
import { useStep } from '../stepper';
|
import { useStep } from '../stepper';
|
||||||
@ -72,6 +73,18 @@ import {
|
|||||||
} from './document-flow-root';
|
} from './document-flow-root';
|
||||||
import type { DocumentFlowStep } from './types';
|
import type { DocumentFlowStep } from './types';
|
||||||
|
|
||||||
|
const isExpiryUnit = (
|
||||||
|
value: unknown,
|
||||||
|
): value is 'minutes' | 'hours' | 'days' | 'weeks' | 'months' => {
|
||||||
|
return (
|
||||||
|
value === 'minutes' ||
|
||||||
|
value === 'hours' ||
|
||||||
|
value === 'days' ||
|
||||||
|
value === 'weeks' ||
|
||||||
|
value === 'months'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export type AddSettingsFormProps = {
|
export type AddSettingsFormProps = {
|
||||||
documentFlow: DocumentFlowStep;
|
documentFlow: DocumentFlowStep;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
@ -101,6 +114,9 @@ export const AddSettingsFormPartial = ({
|
|||||||
documentAuth: document.authOptions,
|
documentAuth: document.authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const documentExpiryUnit = document.documentMeta?.expiryUnit;
|
||||||
|
const initialExpiryUnit = isExpiryUnit(documentExpiryUnit) ? documentExpiryUnit : undefined;
|
||||||
|
|
||||||
const form = useForm<TAddSettingsFormSchema>({
|
const form = useForm<TAddSettingsFormSchema>({
|
||||||
resolver: zodResolver(ZAddSettingsFormSchema),
|
resolver: zodResolver(ZAddSettingsFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -120,6 +136,8 @@ export const AddSettingsFormPartial = ({
|
|||||||
redirectUrl: document.documentMeta?.redirectUrl ?? '',
|
redirectUrl: document.documentMeta?.redirectUrl ?? '',
|
||||||
language: document.documentMeta?.language ?? 'en',
|
language: document.documentMeta?.language ?? 'en',
|
||||||
signatureTypes: extractTeamSignatureSettings(document.documentMeta),
|
signatureTypes: extractTeamSignatureSettings(document.documentMeta),
|
||||||
|
expiryAmount: document.documentMeta?.expiryAmount ?? undefined,
|
||||||
|
expiryUnit: initialExpiryUnit,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -130,6 +148,9 @@ export const AddSettingsFormPartial = ({
|
|||||||
(recipient) => recipient.sendStatus === SendStatus.SENT,
|
(recipient) => recipient.sendStatus === SendStatus.SENT,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const expiryAmount = useWatch({ control: form.control, name: 'meta.expiryAmount' });
|
||||||
|
const expiryUnit = useWatch({ control: form.control, name: 'meta.expiryUnit' });
|
||||||
|
|
||||||
const canUpdateVisibility = match(currentTeamMemberRole)
|
const canUpdateVisibility = match(currentTeamMemberRole)
|
||||||
.with(TeamMemberRole.ADMIN, () => true)
|
.with(TeamMemberRole.ADMIN, () => true)
|
||||||
.with(
|
.with(
|
||||||
@ -522,6 +543,33 @@ export const AddSettingsFormPartial = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormLabel className="mb-4 block">
|
||||||
|
<Trans>Link Expiry</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<ExpirySettingsPicker
|
||||||
|
value={{
|
||||||
|
expiryDuration:
|
||||||
|
expiryAmount && expiryUnit
|
||||||
|
? {
|
||||||
|
amount: expiryAmount,
|
||||||
|
unit: expiryUnit,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
disabled={documentHasBeenSent}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value.expiryDuration) {
|
||||||
|
form.setValue('meta.expiryAmount', value.expiryDuration.amount);
|
||||||
|
form.setValue('meta.expiryUnit', value.expiryDuration.unit);
|
||||||
|
} else {
|
||||||
|
form.setValue('meta.expiryAmount', undefined);
|
||||||
|
form.setValue('meta.expiryUnit', undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|||||||
@ -46,6 +46,8 @@ export const ZAddSettingsFormSchema = z.object({
|
|||||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
||||||
message: msg`At least one signature type must be enabled`.id,
|
message: msg`At least one signature type must be enabled`.id,
|
||||||
}),
|
}),
|
||||||
|
expiryAmount: z.number().int().min(1).optional(),
|
||||||
|
expiryUnit: z.enum(['minutes', 'hours', 'days', 'weeks', 'months']).optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -63,6 +63,8 @@ export type AddSignersFormProps = {
|
|||||||
fields: Field[];
|
fields: Field[];
|
||||||
signingOrder?: DocumentSigningOrder | null;
|
signingOrder?: DocumentSigningOrder | null;
|
||||||
allowDictateNextSigner?: boolean;
|
allowDictateNextSigner?: boolean;
|
||||||
|
expiryAmount?: number | null;
|
||||||
|
expiryUnit?: 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | null;
|
||||||
onSubmit: (_data: TAddSignersFormSchema) => void;
|
onSubmit: (_data: TAddSignersFormSchema) => void;
|
||||||
onAutoSave: (_data: TAddSignersFormSchema) => Promise<AutoSaveResponse>;
|
onAutoSave: (_data: TAddSignersFormSchema) => Promise<AutoSaveResponse>;
|
||||||
isDocumentPdfLoaded: boolean;
|
isDocumentPdfLoaded: boolean;
|
||||||
@ -74,6 +76,8 @@ export const AddSignersFormPartial = ({
|
|||||||
fields,
|
fields,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
allowDictateNextSigner,
|
allowDictateNextSigner,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onAutoSave,
|
onAutoSave,
|
||||||
isDocumentPdfLoaded,
|
isDocumentPdfLoaded,
|
||||||
@ -138,6 +142,10 @@ export const AddSignersFormPartial = ({
|
|||||||
: defaultRecipients,
|
: defaultRecipients,
|
||||||
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
||||||
allowDictateNextSigner: allowDictateNextSigner ?? false,
|
allowDictateNextSigner: allowDictateNextSigner ?? false,
|
||||||
|
meta: {
|
||||||
|
expiryAmount: expiryAmount ?? undefined,
|
||||||
|
expiryUnit: expiryUnit ?? undefined,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,10 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||||
|
import {
|
||||||
|
ZDocumentExpiryAmountSchema,
|
||||||
|
ZDocumentExpiryUnitSchema,
|
||||||
|
} from '@documenso/lib/types/document-meta';
|
||||||
|
|
||||||
export const ZAddSignersFormSchema = z.object({
|
export const ZAddSignersFormSchema = z.object({
|
||||||
signers: z.array(
|
signers: z.array(
|
||||||
@ -21,6 +25,10 @@ export const ZAddSignersFormSchema = z.object({
|
|||||||
),
|
),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||||
allowDictateNextSigner: z.boolean().default(false),
|
allowDictateNextSigner: z.boolean().default(false),
|
||||||
|
meta: z.object({
|
||||||
|
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
|
||||||
|
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
|
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
|
||||||
|
|||||||
79
packages/ui/primitives/duration-selector.tsx
Normal file
79
packages/ui/primitives/duration-selector.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import type { DurationValue } from '@documenso/lib/utils/expiry';
|
||||||
|
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { Input } from './input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
|
||||||
|
|
||||||
|
export interface DurationSelectorProps {
|
||||||
|
value?: DurationValue;
|
||||||
|
onChange?: (value: DurationValue) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
minAmount?: number;
|
||||||
|
maxAmount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_UNITS: Array<{ value: string; label: string; labelPlural: string }> = [
|
||||||
|
{ value: 'minutes', label: 'Minute', labelPlural: 'Minutes' },
|
||||||
|
{ value: 'hours', label: 'Hour', labelPlural: 'Hours' },
|
||||||
|
{ value: 'days', label: 'Day', labelPlural: 'Days' },
|
||||||
|
{ value: 'weeks', label: 'Week', labelPlural: 'Weeks' },
|
||||||
|
{ value: 'months', label: 'Month', labelPlural: 'Months' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DurationSelector = ({
|
||||||
|
value = { amount: 1, unit: 'days' },
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
minAmount = 1,
|
||||||
|
maxAmount = 365,
|
||||||
|
}: DurationSelectorProps) => {
|
||||||
|
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const amount = parseInt(event.target.value, 10);
|
||||||
|
if (!isNaN(amount) && amount >= minAmount && amount <= maxAmount) {
|
||||||
|
onChange?.({ ...value, amount });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnitChange = (unit: string) => {
|
||||||
|
onChange?.({ ...value, unit });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUnitLabel = (unit: string, amount: number) => {
|
||||||
|
const unitConfig = TIME_UNITS.find((u) => u.value === unit);
|
||||||
|
if (!unitConfig) return unit;
|
||||||
|
|
||||||
|
return amount === 1 ? unitConfig.label : unitConfig.labelPlural;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-2', className)}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value.amount}
|
||||||
|
onChange={handleAmountChange}
|
||||||
|
disabled={disabled}
|
||||||
|
min={minAmount}
|
||||||
|
max={maxAmount}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
|
<Select value={value.unit} onValueChange={handleUnitChange} disabled={disabled}>
|
||||||
|
<SelectTrigger className="w-24">
|
||||||
|
<SelectValue>{getUnitLabel(value.unit, value.amount)}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TIME_UNITS.map((unit) => (
|
||||||
|
<SelectItem key={unit.value} value={unit.value}>
|
||||||
|
{getUnitLabel(unit.value, value.amount)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
132
packages/ui/primitives/expiry-settings-picker.tsx
Normal file
132
packages/ui/primitives/expiry-settings-picker.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { calculateExpiryDate, formatExpiryDate } from '@documenso/lib/utils/expiry';
|
||||||
|
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { DurationSelector } from './duration-selector';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from './form/form';
|
||||||
|
|
||||||
|
const ZExpirySettingsSchema = z.object({
|
||||||
|
expiryDuration: z
|
||||||
|
.object({
|
||||||
|
amount: z.number().int().min(1),
|
||||||
|
unit: z.enum(['minutes', 'hours', 'days', 'weeks', 'months']),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ExpirySettings = z.infer<typeof ZExpirySettingsSchema>;
|
||||||
|
|
||||||
|
export interface ExpirySettingsPickerProps {
|
||||||
|
className?: string;
|
||||||
|
defaultValues?: Partial<ExpirySettings>;
|
||||||
|
disabled?: boolean;
|
||||||
|
onValueChange?: (value: ExpirySettings) => void;
|
||||||
|
value?: ExpirySettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExpirySettingsPicker = ({
|
||||||
|
className,
|
||||||
|
defaultValues = {
|
||||||
|
expiryDuration: undefined,
|
||||||
|
},
|
||||||
|
disabled = false,
|
||||||
|
onValueChange,
|
||||||
|
value,
|
||||||
|
}: ExpirySettingsPickerProps) => {
|
||||||
|
const form = useForm<ExpirySettings>({
|
||||||
|
resolver: zodResolver(ZExpirySettingsSchema),
|
||||||
|
defaultValues,
|
||||||
|
mode: 'onChange',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { watch, setValue, getValues } = form;
|
||||||
|
const expiryDuration = watch('expiryDuration');
|
||||||
|
|
||||||
|
const calculatedExpiryDate = React.useMemo(() => {
|
||||||
|
if (expiryDuration?.amount && expiryDuration?.unit) {
|
||||||
|
return calculateExpiryDate(expiryDuration);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [expiryDuration]);
|
||||||
|
|
||||||
|
// Call onValueChange when form values change
|
||||||
|
React.useEffect(() => {
|
||||||
|
const subscription = watch((value) => {
|
||||||
|
if (onValueChange) {
|
||||||
|
onValueChange(value as ExpirySettings);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [watch, onValueChange]);
|
||||||
|
|
||||||
|
// Keep internal form state in sync when a controlled value is provided
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (value === undefined) return;
|
||||||
|
|
||||||
|
const current = getValues('expiryDuration');
|
||||||
|
const next = value.expiryDuration;
|
||||||
|
|
||||||
|
const amountsDiffer = (current?.amount ?? null) !== (next?.amount ?? null);
|
||||||
|
const unitsDiffer = (current?.unit ?? null) !== (next?.unit ?? null);
|
||||||
|
|
||||||
|
if (amountsDiffer || unitsDiffer) {
|
||||||
|
setValue('expiryDuration', next, {
|
||||||
|
shouldDirty: false,
|
||||||
|
shouldTouch: false,
|
||||||
|
shouldValidate: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [value, getValues, setValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
<Form {...form}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="expiryDuration"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Link Expiry</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>Set an expiry duration for signing links (leave empty to disable)</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<DurationSelector
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
minAmount={1}
|
||||||
|
maxAmount={365}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{calculatedExpiryDate && (
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>Links will expire on: {formatExpiryDate(calculatedExpiryDate)}</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user