Compare commits

..

12 Commits

Author SHA1 Message Date
0a0d2d1a82 fix: wip 2025-10-16 13:46:45 +11:00
a26a740fe5 feat: add horizontal radio 2025-10-15 11:17:57 +11:00
f48813bb3c fix: test 2025-10-14 16:00:58 +11:00
304c519c30 fix: additional backwards compat 2025-10-14 15:47:02 +11:00
0eef4cd7e6 fix: additional backwards compat 2025-10-14 15:19:09 +11:00
bddaa5ec66 fix: reorder migrations 2025-10-13 17:29:48 +11:00
3be0d84786 fix: additional backwards compat 2025-10-13 17:26:16 +11:00
50572435ad fix: cleanup 2025-10-13 16:02:22 +11:00
6f70548bb5 fix: multi email bug 2025-10-13 13:03:05 +11:00
0da8e7dbc6 feat: add envelope editor 2025-10-12 23:35:54 +11:00
bf89bc781b feat: migrate templates and documents to envelope model 2025-10-09 16:13:41 +11:00
eec2307634 fix: migrate template metadata 2025-10-09 16:11:40 +11:00
80 changed files with 899 additions and 2727 deletions

View File

@ -127,15 +127,15 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
const distributionMethod = watch('meta.distributionMethod');
const everySignerHasSignature = useMemo(
const recipientsMissingSignatureFields = useMemo(
() =>
envelope.recipients
.filter((recipient) => recipient.role === RecipientRole.SIGNER)
.every((recipient) =>
envelope.fields.some(
envelope.recipients.filter(
(recipient) =>
recipient.role === RecipientRole.SIGNER &&
!envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
),
),
),
[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>
</DialogDescription>
</DialogHeader>
{everySignerHasSignature ? (
{recipientsMissingSignatureFields.length === 0 ? (
<Form {...form}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}>
@ -350,6 +350,8 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
</div>
) : (
<ul className="text-muted-foreground divide-y">
{/* Todo: Envelopes - I don't think this section shows up */}
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
@ -427,10 +429,13 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
<>
<Alert variant="warning">
<AlertDescription>
<Trans>
Some signers have not been assigned a signature field. Please assign at least 1
signature field to each signer before proceeding.
</Trans>
<Trans>The following signers are missing signature fields:</Trans>
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingSignatureFields.map((recipient) => (
<li key={recipient.id}>{recipient.email}</li>
))}
</ul>
</AlertDescription>
</Alert>

View File

@ -1,40 +1,15 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useLingui } from '@lingui/react/macro';
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 { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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>;
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@documenso/ui/primitives/command';
export type SignFieldDropdownDialogProps = {
fieldMeta: TDropdownFieldMeta;
@ -46,72 +21,20 @@ export const SignFieldDropdownDialog = createCallable<SignFieldDropdownDialogPro
const values = fieldMeta.values?.map((value) => value.value) ?? [];
const form = useForm<TSignFieldDropdownFormSchema>({
resolver: zodResolver(ZSignFieldDropdownFormSchema),
defaultValues: {
dropdown: fieldMeta.defaultValue,
},
});
return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Sign Dropdown Field</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Select a value to sign into the field</Trans>
</DialogDescription>
</DialogHeader>
<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>
<CommandDialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<CommandInput placeholder={t`Select an option`} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading={t`Options`}>
{values.map((value, i) => (
<CommandItem onSelect={() => call.end(value)} key={i} value={value}>
{value}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
);
},
);

View File

@ -45,26 +45,32 @@ const ZDropdownFieldFormSchema = z
.min(1, {
message: msg`Dropdown must have at least one option`.id,
})
.refine(
(data) => {
// Todo: Envelopes - This doesn't work.
console.log({
data,
});
if (data) {
const values = data.map((item) => item.value);
return new Set(values).size === values.length;
.superRefine((values, ctx) => {
const seen = new Map<string, number[]>(); // value → indices
values.forEach((item, index) => {
const key = item.value;
if (!seen.has(key)) {
seen.set(key, []);
}
return true;
},
{
message: 'Duplicate values are not allowed',
},
),
seen.get(key)!.push(index);
});
for (const [key, indices] of seen) {
if (indices.length > 1 && key.trim() !== '') {
for (const i of indices) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: msg`Duplicate values are not allowed`.id,
path: [i, 'value'],
});
}
}
}
}),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
})
// Todo: Envelopes - This doesn't work
.refine(
(data) => {
// Default value must be one of the available options
@ -111,7 +117,20 @@ export const EditorFieldDropdownForm = ({
const addValue = () => {
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);
};
@ -127,6 +146,10 @@ export const EditorFieldDropdownForm = ({
newValues.splice(index, 1);
form.setValue('values', newValues);
if (form.getValues('defaultValue') === newValues[index].value) {
form.setValue('defaultValue', undefined);
}
};
useEffect(() => {
@ -163,20 +186,26 @@ export const EditorFieldDropdownForm = ({
</FormLabel>
<FormControl>
<Select
// Todo: Envelopes - This is buggy, removing/adding should update the default value.
{...field}
value={field.value}
onValueChange={(val) => field.onChange(val)}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === undefined ? null : value)}
>
{/* Todo: Envelopes - THis is cooked */}
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectValue placeholder={t`Default Value`} />
</SelectTrigger>
<SelectContent position="popper">
{(formValues.values || []).map((item, index) => (
<SelectItem key={index} value={item.value || ''}>
{item.value}
</SelectItem>
))}
{(formValues.values || [])
.filter((item) => item.value)
.map((item, index) => (
<SelectItem key={index} value={item.value || ''}>
{item.value}
</SelectItem>
))}
<SelectItem value={'-1'}>
<Trans>None</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>

View File

@ -1,15 +1,32 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { PlusIcon, Trash } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import type { z } from 'zod';
import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta';
import {
type TRadioFieldMeta as RadioFieldMeta,
ZRadioFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Separator } from '@documenso/ui/primitives/separator';
import {
@ -17,31 +34,26 @@ import {
EditorGenericRequiredField,
} from './editor-field-generic-field-forms';
const ZRadioFieldFormSchema = z
.object({
label: z.string().optional(),
values: z
.object({ id: z.number(), checked: z.boolean(), value: z.string() })
.array()
.min(1)
.optional(),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
})
.refine(
(data) => {
// There cannot be more than one checked option
if (data.values) {
const checkedValues = data.values.filter((option) => option.checked);
return checkedValues.length <= 1;
}
return true;
},
{
message: 'There cannot be more than one checked option',
path: ['values'],
},
);
const ZRadioFieldFormSchema = ZRadioFieldMeta.pick({
label: true,
direction: true,
values: true,
required: true,
readOnly: true,
}).refine(
(data) => {
// There cannot be more than one checked option
if (data.values) {
const checkedValues = data.values.filter((option) => option.checked);
return checkedValues.length <= 1;
}
return true;
},
{
message: 'There cannot be more than one checked option',
path: ['values'],
},
);
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
@ -53,9 +65,12 @@ export type EditorFieldRadioFormProps = {
export const EditorFieldRadioForm = ({
value = {
type: 'radio',
direction: 'vertical',
},
onValueChange,
}: EditorFieldRadioFormProps) => {
const { t } = useLingui();
const form = useForm<TRadioFieldFormSchema>({
resolver: zodResolver(ZRadioFieldFormSchema),
mode: 'onChange',
@ -64,6 +79,7 @@ export const EditorFieldRadioForm = ({
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
required: value.required || false,
readOnly: value.readOnly || false,
direction: value.direction || 'vertical',
},
});
@ -107,7 +123,35 @@ export const EditorFieldRadioForm = ({
return (
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2 pb-2">
<fieldset className="flex flex-col gap-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} />
<EditorGenericReadOnlyField formControl={form.control} />

View File

@ -223,8 +223,6 @@ export const DocumentEditForm = ({
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
expiryAmount: data.meta.expiryAmount,
expiryUnit: data.meta.expiryUnit,
},
}),
@ -249,8 +247,6 @@ export const DocumentEditForm = ({
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
expiryAmount: data.meta.expiryAmount,
expiryUnit: data.meta.expiryUnit,
},
}),
@ -480,17 +476,6 @@ export const DocumentEditForm = ({
recipients={recipients}
signingOrder={document.documentMeta?.signingOrder}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
expiryAmount={document.documentMeta?.expiryAmount}
expiryUnit={
document.documentMeta?.expiryUnit as
| 'minutes'
| 'hours'
| 'days'
| 'weeks'
| 'months'
| null
| undefined
}
fields={fields}
onSubmit={onAddSignersFormSubmit}
onAutoSave={onAddSignersFormAutoSave}

View File

@ -156,14 +156,6 @@ export const DocumentPageViewRecipients = ({
</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 &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && (

View File

@ -96,7 +96,7 @@ export const EnvelopeEditorFieldDragDrop = ({
selectedRecipientId,
selectedEnvelopeItemId,
}: EnvelopeEditorFieldDragDropProps) => {
const { envelope, editorFields, isTemplate } = useCurrentEnvelopeEditor();
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { t } = useLingui();
@ -262,6 +262,10 @@ export const EnvelopeEditorFieldDragDrop = ({
};
}, [onMouseClick, onMouseMove, selectedField]);
const selectedRecipientColor = useMemo(() => {
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
}, [selectedRecipientId, getRecipientColorKey]);
return (
<>
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
@ -273,12 +277,23 @@ export const EnvelopeEditorFieldDragDrop = ({
onClick={() => setSelectedField(field.type)}
onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined}
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"
className={cn(
'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
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
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" />}
@ -292,8 +307,7 @@ export const EnvelopeEditorFieldDragDrop = ({
<div
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]',
// selectedSignerStyles?.base,
RECIPIENT_COLOR_STYLES.yellow.base, // Todo: Envelopes
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,

View File

@ -3,15 +3,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Transformer } from 'konva/lib/shapes/Transformer';
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 { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
@ -26,27 +23,10 @@ import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
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 { envelope, 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 interactiveTransformer = useRef<Transformer | null>(null);
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
@ -54,10 +34,17 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const [isFieldChanging, setIsFieldChanging] = useState(false);
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext;
const localPageFields = useMemo(
() =>
@ -68,44 +55,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
[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>) => {
console.log('Field resized or moved');
@ -120,6 +69,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const fieldGroup = event.target as Konva.Group;
const fieldFormId = fieldGroup.id();
// Note: This values are scaled.
const {
width: fieldPixelWidth,
height: fieldPixelHeight,
@ -130,7 +80,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
skipShadow: true,
});
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(container);
const pageHeight = scaledViewport.height;
const pageWidth = scaledViewport.width;
// Calculate x and y as a percentage of the page width and height
const positionPercentX = (fieldX / pageWidth) * 100;
@ -165,7 +116,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
};
const renderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current || !interactiveTransformer.current) {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
}
@ -174,7 +125,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const isFieldEditable =
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
const { fieldGroup, isFirstRender } = renderField({
const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: field.formId,
@ -183,8 +135,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
inserted: false,
fieldMeta: field.fieldMeta,
},
pageWidth: viewport.width,
pageHeight: viewport.height,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: getRecipientColorKey(field.recipientId),
editable: isFieldEditable,
mode: 'edit',
@ -210,24 +162,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
};
/**
* Create the initial Konva page canvas and initialize all fields and interactions.
* Initialize the Konva page canvas and 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);
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
// Initialize snap guides layer
// snapGuideLayer.current = initializeSnapGuides(stage.current);
// Add transformer for resizing and rotating.
interactiveTransformer.current = createInteractiveTransformer(stage.current, pageLayer.current);
interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer);
// Render the fields.
for (const field of localPageFields) {
@ -235,12 +177,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}
// Handle stage click to deselect.
stage.current?.on('click', (e) => {
currentStage.on('click', (e) => {
removePendingField();
if (e.target === stage.current) {
setSelectedFields([]);
pageLayer.current?.batchDraw();
currentPageLayer.batchDraw();
}
});
@ -267,12 +209,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
setSelectedFields([e.target]);
};
stage.current?.on('dragstart', onDragStartOrEnd);
stage.current?.on('dragend', onDragStartOrEnd);
stage.current?.on('transformstart', () => setIsFieldChanging(true));
stage.current?.on('transformend', () => setIsFieldChanging(false));
currentStage.on('dragstart', onDragStartOrEnd);
currentStage.on('dragend', onDragStartOrEnd);
currentStage.on('transformstart', () => setIsFieldChanging(true));
currentStage.on('transformend', () => setIsFieldChanging(false));
pageLayer.current.batchDraw();
currentPageLayer.batchDraw();
};
/**
@ -284,7 +226,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
* - Selecting multiple fields
* - Selecting empty area to create fields
*/
const createInteractiveTransformer = (stage: Konva.Stage, layer: Konva.Layer) => {
const createInteractiveTransformer = (
currentStage: Konva.Stage,
currentPageLayer: Konva.Layer,
) => {
const transformer = new Konva.Transformer({
rotateEnabled: false,
keepRatio: false,
@ -301,36 +246,39 @@ export default function EnvelopeEditorFieldsPageRenderer() {
},
});
layer.add(transformer);
currentPageLayer.add(transformer);
// Add selection rectangle.
const selectionRectangle = new Konva.Rect({
fill: 'rgba(24, 160, 251, 0.3)',
visible: false,
});
layer.add(selectionRectangle);
currentPageLayer.add(selectionRectangle);
let x1: number;
let y1: number;
let x2: number;
let y2: number;
stage.on('mousedown touchstart', (e) => {
currentStage.on('mousedown touchstart', (e) => {
// do nothing if we mousedown on any shape
if (e.target !== stage) {
if (e.target !== currentStage) {
return;
}
const pointerPosition = stage.getPointerPosition();
const pointerPosition = currentStage.getPointerPosition();
if (!pointerPosition) {
return;
}
x1 = pointerPosition.x;
y1 = pointerPosition.y;
x2 = pointerPosition.x;
y2 = pointerPosition.y;
console.log(`pointerPosition.x: ${pointerPosition.x}`);
console.log(`pointerPosition.y: ${pointerPosition.y}`);
x1 = pointerPosition.x / scale;
y1 = pointerPosition.y / scale;
x2 = pointerPosition.x / scale;
y2 = pointerPosition.y / scale;
selectionRectangle.setAttrs({
x: x1,
@ -341,7 +289,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
});
});
stage.on('mousemove touchmove', () => {
currentStage.on('mousemove touchmove', () => {
// do nothing if we didn't start selection
if (!selectionRectangle.visible()) {
return;
@ -349,14 +297,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
selectionRectangle.moveToTop();
const pointerPosition = stage.getPointerPosition();
const pointerPosition = currentStage.getPointerPosition();
if (!pointerPosition) {
return;
}
x2 = pointerPosition.x;
y2 = pointerPosition.y;
x2 = pointerPosition.x / scale;
y2 = pointerPosition.y / scale;
selectionRectangle.setAttrs({
x: Math.min(x1, x2),
@ -366,7 +314,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
});
});
stage.on('mouseup touchend', () => {
currentStage.on('mouseup touchend', () => {
// do nothing if we didn't start selection
if (!selectionRectangle.visible()) {
return;
@ -377,38 +325,41 @@ export default function EnvelopeEditorFieldsPageRenderer() {
selectionRectangle.visible(false);
});
const stageFieldGroups = stage.find('.field-group') || [];
const stageFieldGroups = currentStage.find('.field-group') || [];
const box = selectionRectangle.getClientRect();
const selectedFieldGroups = stageFieldGroups.filter(
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
);
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.
if (
selectedFieldGroups.length === 0 &&
canvasElement.current &&
box.width > MIN_FIELD_WIDTH_PX &&
box.height > MIN_FIELD_HEIGHT_PX &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient &&
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
) {
const pendingFieldCreation = new Konva.Rect({
name: 'pending-field-creation',
x: box.x,
y: box.y,
width: box.width,
height: box.height,
x: box.x / scale,
y: box.y / scale,
width: unscaledBoxWidth,
height: unscaledBoxHeight,
fill: 'rgba(24, 160, 251, 0.3)',
});
layer.add(pendingFieldCreation);
currentPageLayer.add(pendingFieldCreation);
setPendingFieldCreation(pendingFieldCreation);
}
});
// Clicks should select/deselect shapes
stage.on('click tap', function (e) {
currentStage.on('click tap', function (e) {
// if we are selecting with rect, do nothing
if (
selectionRectangle.visible() &&
@ -419,7 +370,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}
// If empty area clicked, remove all selections
if (e.target === stage) {
if (e.target === stage.current) {
setSelectedFields([]);
return;
}
@ -555,15 +506,13 @@ export default function EnvelopeEditorFieldsPageRenderer() {
return;
}
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(canvasElement.current);
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
width: pixelWidth,
height: pixelHeight,
positionX: pixelX,
positionY: pixelY,
pageWidth,
pageHeight,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
});
editorFields.addField({
@ -597,7 +546,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}
return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current &&
!isFieldChanging && (
@ -654,8 +606,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
<div
style={{
position: 'absolute',
top: pendingFieldCreation.y() + pendingFieldCreation.getClientRect().height + 5 + 'px',
left: pendingFieldCreation.x() + pendingFieldCreation.getClientRect().width / 2 + 'px',
top:
pendingFieldCreation.y() * scale +
pendingFieldCreation.getClientRect().height +
5 +
'px',
left:
pendingFieldCreation.x() * scale +
pendingFieldCreation.getClientRect().width / 2 +
'px',
transform: 'translateX(-50%)',
zIndex: 50,
}}
@ -673,13 +632,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
</div>
)}
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
{/* 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
className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement}
width={viewport.width}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
);

View File

@ -60,7 +60,7 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
};
export const EnvelopeEditorPageFields = () => {
export const EnvelopeEditorFieldsPage = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -109,7 +109,7 @@ export const EnvelopeEditorPageFields = () => {
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */}
<div className="mt-4 flex justify-center">
<div className="mt-4 flex justify-center p-4">
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
) : (

View File

@ -1,176 +0,0 @@
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>
);
}

View File

@ -13,11 +13,9 @@ import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
const EnvelopeEditorPagePreviewRenderer = lazy(
async () => import('./envelope-editor-page-preview-renderer'),
);
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
export const EnvelopeEditorPagePreview = () => {
export const EnvelopeEditorPreviewPage = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -51,7 +49,7 @@ export const EnvelopeEditorPagePreview = () => {
</Alert>
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorPagePreviewRenderer} />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" />

View File

@ -41,7 +41,7 @@ type LocalFile = {
isError: boolean;
};
export const EnvelopeEditorPageUpload = () => {
export const EnvelopeEditorUploadPage = () => {
const team = useCurrentTeam();
const { t } = useLingui();
@ -224,8 +224,12 @@ export const EnvelopeEditorPageUpload = () => {
<div className="mx-auto max-w-4xl space-y-6 p-8">
<Card backdropBlur={false} className="border">
<CardHeader className="pb-3">
<CardTitle>Documents</CardTitle>
<CardDescription>Add and configure multiple documents</CardDescription>
<CardTitle>
<Trans>Documents</Trans>
</CardTitle>
<CardDescription>
<Trans>Add and configure multiple documents</Trans>
</CardDescription>
</CardHeader>
<CardContent>

View File

@ -39,10 +39,10 @@ import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-l
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldsPage } from './envelope-editor-fields-page';
import EnvelopeEditorHeader from './envelope-editor-header';
import { EnvelopeEditorPageFields } from './envelope-editor-page-fields';
import { EnvelopeEditorPagePreview } from './envelope-editor-page-preview';
import { EnvelopeEditorPageUpload } from './envelope-editor-page-upload';
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
@ -128,6 +128,18 @@ 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(() => {
if (!isAutosaving) {
setIsStepLoading(false);
@ -151,7 +163,9 @@ export default function EnvelopeEditor() {
{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">
Step {currentStepData.order}/{envelopeEditorSteps.length}
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
@ -340,13 +354,12 @@ export default function EnvelopeEditor() {
{/* Main Content - Changes based on current step */}
<div className="flex-1 overflow-y-auto">
<p>{isAutosaving ? 'Autosaving...' : 'Not autosaving'}</p>
<AnimateGenericFadeInOut key={currentStep}>
{match({ currentStep, isStepLoading })
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorPageUpload />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorPageFields />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPagePreview />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
.exhaustive()}
</AnimateGenericFadeInOut>
</div>

View File

@ -1,41 +1,31 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
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 Konva from 'konva';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
export default function EnvelopeGenericPageRenderer() {
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 canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
});
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 { _className, scale } = pageContext;
const localPageFields = useMemo(
() =>
@ -46,44 +36,6 @@ export default function EnvelopeGenericPageRenderer() {
[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]) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
@ -91,6 +43,7 @@ export default function EnvelopeGenericPageRenderer() {
}
renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: field.id.toString(),
@ -103,8 +56,8 @@ export default function EnvelopeGenericPageRenderer() {
inserted: false,
fieldMeta: field.fieldMeta,
},
pageWidth: viewport.width,
pageHeight: viewport.height,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
// color: getRecipientColorKey(field.recipientId),
color: 'purple', // Todo
editable: false,
@ -113,25 +66,15 @@ export default function EnvelopeGenericPageRenderer() {
};
/**
* Create the initial Konva page canvas and initialize all fields and interactions.
* Initialize the Konva page canvas and 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);
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
pageLayer.current.batchDraw();
currentPageLayer.batchDraw();
};
/**
@ -167,14 +110,19 @@ export default function EnvelopeGenericPageRenderer() {
}
return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
<div
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>
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement}
width={viewport.width}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
);

View File

@ -1,14 +1,12 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { type Field, FieldType } from '@prisma/client';
import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import { type Field, FieldType, type Signature } from '@prisma/client';
import type Konva from 'konva';
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 { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { ZFullFieldSchema } from '@documenso/lib/types/field';
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
@ -28,18 +26,6 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
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 { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -58,21 +44,20 @@ export default function EnvelopeSignerPageRenderer() {
setSignature,
} = useRequiredEnvelopeSigningContext();
console.log({ fullName });
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext;
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(
() =>
recipientFields.filter(
@ -82,45 +67,7 @@ export default function EnvelopeSignerPageRenderer() {
[recipientFields, 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 = (unparsedField: Field) => {
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
@ -137,6 +84,7 @@ export default function EnvelopeSignerPageRenderer() {
}
const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: fieldToRender.id.toString(),
@ -145,9 +93,10 @@ export default function EnvelopeSignerPageRenderer() {
height: Number(fieldToRender.height),
positionX: Number(fieldToRender.positionX),
positionY: Number(fieldToRender.positionY),
signature: unparsedField.signature,
},
pageWidth: viewport.width,
pageHeight: viewport.height,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color,
mode: 'sign',
});
@ -357,29 +306,19 @@ export default function EnvelopeSignerPageRenderer() {
};
/**
* Create the initial Konva page canvas and initialize all fields and interactions.
* Initialize the Konva page canvas and 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);
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
console.log({
localPageFields,
});
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
}
pageLayer.current.batchDraw();
currentPageLayer.batchDraw();
};
/**
@ -392,7 +331,7 @@ export default function EnvelopeSignerPageRenderer() {
localPageFields.forEach((field) => {
console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field);
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
});
pageLayer.current.batchDraw();
@ -403,14 +342,19 @@ export default function EnvelopeSignerPageRenderer() {
}
return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
<div
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>
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement}
width={viewport.width}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
);

View File

@ -19,6 +19,8 @@ import { DocumentUploadButton } from '~/components/general/document/document-upl
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeUploadButton } from '../document/envelope-upload-button';
export type FolderGridProps = {
type: FolderType;
parentId: string | null;
@ -98,7 +100,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
<div className="flex gap-4 sm:flex-row sm:justify-end">
{/* Todo: Envelopes - Feature flag */}
{/* <EnvelopeUploadButton type={type} folderId={parentId || undefined} /> */}
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
{type === FolderType.DOCUMENT ? (
<DocumentUploadButton />

View File

@ -41,9 +41,6 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
case RecipientStatusType.REJECTED:
classes = 'bg-red-200 text-red-800';
break;
case RecipientStatusType.EXPIRED:
classes = 'bg-orange-200 text-orange-800';
break;
default:
break;
}

View File

@ -48,20 +48,13 @@ export const StackAvatarsWithTooltip = ({
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
);
const expiredRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.EXPIRED,
);
const sortedRecipients = useMemo(() => {
const otherRecipients = recipients.filter(
(recipient) =>
getRecipientType(recipient) !== RecipientStatusType.REJECTED &&
getRecipientType(recipient) !== RecipientStatusType.EXPIRED,
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
);
return [
...rejectedRecipients.sort((a, b) => a.id - b.id),
...expiredRecipients.sort((a, b) => a.id - b.id),
...otherRecipients.sort((a, b) => {
return a.id - b.id;
}),
@ -124,30 +117,6 @@ export const StackAvatarsWithTooltip = ({
</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 && (
<div>
<h1 className="text-base font-medium">

View File

@ -2,7 +2,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircle, Clock, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
@ -36,7 +36,6 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
const role = recipient?.role;
const isCurrentTeamDocument = team && row.team?.url === team.url;
@ -88,15 +87,8 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
isPending,
isComplete,
isSigned,
isExpired,
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(
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
() => (

View File

@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus as DocumentStatusEnum } from '@prisma/client';
import { RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircleIcon, Clock, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
@ -193,7 +193,6 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
const isPending = row.status === DocumentStatusEnum.PENDING;
const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
const role = recipient?.role;
if (!recipient) {
@ -231,14 +230,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
isPending,
isComplete,
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 }, () => (
<Button className="w-32" asChild>
<Link to={`/sign/${recipient?.token}`}>

View File

@ -16,7 +16,6 @@ import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envel
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 { 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 { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
@ -26,7 +25,6 @@ import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settin
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/expiry';
import { prisma } from '@documenso/prisma';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
@ -138,13 +136,6 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
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) {
throw redirect(`/sign/${token}/rejected`);
}
@ -248,13 +239,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
if (isRecipientExpired(recipient)) {
const expiredRecipient = await expireRecipient({ recipientId: recipient.id });
if (expiredRecipient) {
throw redirect(`/sign/${token}/expired`);
}
}
if (isRejected) {
throw redirect(`/sign/${token}/rejected`);
}

View File

@ -1,141 +0,0 @@
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>
);
}

View File

@ -4,6 +4,7 @@ import { ZBaseEmbedDataSchema } from './embed-base-schemas';
export const ZBaseEmbedAuthoringSchema = z
.object({
token: z.string(),
externalId: z.string().optional(),
features: z
.object({

View File

@ -1,7 +1,6 @@
import { Hono } from 'hono';
import { rateLimiter } from 'hono-rate-limiter';
import { contextStorage } from 'hono/context-storage';
import { cors } from 'hono/cors';
import { requestId } from 'hono/request-id';
import type { RequestIdVariables } from 'hono/request-id';
import type { Logger } from 'pino';
@ -84,14 +83,12 @@ app.route('/api/auth', auth);
app.route('/api/files', filesRoute);
// API servers.
app.use(`/api/v1/*`, cors());
app.route('/api/v1', tsRestHonoApp);
app.use('/api/jobs/*', jobsClient.getApiHandler());
app.use('/api/trpc/*', reactRouterTrpcServer);
// Unstable API server routes. Order matters for these two.
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));
export default app;

View File

@ -136,6 +136,7 @@ export const useEditorFields = ({
const field: TLocalField = {
...fieldData,
formId: nanoid(12),
...restrictFieldPosValues(fieldData),
};
append(field);
@ -165,7 +166,15 @@ export const useEditorFields = ({
const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) {
update(index, { ...localFields[index], ...updates });
const updatedField = {
...localFields[index],
...updates,
};
update(index, {
...updatedField,
...restrictFieldPosValues(updatedField),
});
triggerFieldsUpdate();
}
},
@ -279,3 +288,14 @@ export const useEditorFields = ({
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)),
};
};

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import { useEffect, useState } from 'react';
import { RefObject, useEffect, useState } from 'react';
/**
* Calculate the width and height of a text element.

View File

@ -0,0 +1,105 @@
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,
};
}

View File

@ -135,7 +135,12 @@ export const EnvelopeEditorProvider = ({
});
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: () => {
onSuccess: ({ recipients }) => {
setEnvelope((prev) => ({
...prev,
recipients,
}));
setAutosaveError(false);
},
onError: (error) => {
@ -215,14 +220,15 @@ export const EnvelopeEditorProvider = ({
const getRecipientColorKey = useCallback(
(recipientId: number) => {
// Todo: Envelopes - Local recipients
const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === recipientId,
);
return AVAILABLE_RECIPIENT_COLORS[Math.max(recipientIndex, 0)];
return AVAILABLE_RECIPIENT_COLORS[
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
},
[envelope.recipients], // Todo: Envelopes - Local recipients
[envelope.recipients],
);
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(

View File

@ -13,7 +13,6 @@ export enum RecipientStatusType {
WAITING = 'waiting',
UNSIGNED = 'unsigned',
REJECTED = 'rejected',
EXPIRED = 'expired',
}
export const getRecipientType = (
@ -28,10 +27,6 @@ export const getRecipientType = (
return RecipientStatusType.REJECTED;
}
if (recipient.signingStatus === SigningStatus.EXPIRED) {
return RecipientStatusType.EXPIRED;
}
if (
recipient.readStatus === ReadStatus.OPENED &&
recipient.signingStatus === SigningStatus.NOT_SIGNED
@ -57,10 +52,6 @@ export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
return RecipientStatusType.UNSIGNED;
}
if (types.includes(RecipientStatusType.EXPIRED)) {
return RecipientStatusType.EXPIRED;
}
if (types.includes(RecipientStatusType.OPENED)) {
return RecipientStatusType.OPENED;
}

View File

@ -15,7 +15,6 @@ export const getRecipientsStats = async () => {
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SigningStatus.REJECTED]: 0,
[SigningStatus.EXPIRED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};

View File

@ -10,7 +10,6 @@ import {
createDocumentAuditLogData,
diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { calculateRecipientExpiry } from '@documenso/lib/utils/expiry';
import { prisma } from '@documenso/prisma';
import type { SupportedLanguageCodes } from '../../constants/i18n';
@ -38,8 +37,6 @@ export type CreateDocumentMetaOptions = {
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
language?: SupportedLanguageCodes;
expiryAmount?: number;
expiryUnit?: string;
requestMetadata: ApiRequestMetadata;
};
@ -62,8 +59,6 @@ export const updateDocumentMeta = async ({
uploadSignatureEnabled,
drawSignatureEnabled,
language,
expiryAmount,
expiryUnit,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
@ -125,30 +120,9 @@ export const updateDocumentMeta = async ({
uploadSignatureEnabled,
drawSignatureEnabled,
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);
// Create audit logs only for document type envelopes.

View File

@ -1,292 +0,0 @@
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;
});
};

View File

@ -1,177 +0,0 @@
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;
});
};

View File

@ -26,7 +26,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
@ -211,39 +210,6 @@ export const resendDocument = async ({
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({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,

View File

@ -87,7 +87,6 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
return {
fileName: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
content: Buffer.from(file),
contentType: 'application/pdf',
};
}),
);

View File

@ -24,7 +24,6 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -57,6 +56,7 @@ export const sendDocument = async ({
recipients: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
},
fields: true,
documentMeta: true,
envelopeItems: {
select: {
@ -166,6 +166,16 @@ 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) => {
if (envelope.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
@ -178,24 +188,6 @@ 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({
where: {
id: envelope.id,

View File

@ -156,9 +156,11 @@ export const setFieldsForDocument = async ({
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value),
numberFieldParsedMeta,
false,
);
if (errors.length > 0) {

View File

@ -25,9 +25,7 @@ import {
} from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { isRecipientExpired } from '../../utils/expiry';
import { validateFieldAuth } from '../document/validate-field-auth';
import { expireRecipient } from '../recipient/expire-recipient';
export type SignFieldWithTokenOptions = {
token: string;
@ -117,11 +115,6 @@ export const signFieldWithToken = async ({
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) {
throw new Error(`Field ${fieldId} has already been inserted`);
}

View File

@ -3,7 +3,6 @@ import { RotationTypes, radiansToDegrees } from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import type { Canvas } from 'skia-canvas';
import { match } from 'ts-pattern';
@ -86,6 +85,7 @@ export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSigna
// Will render onto the layer.
renderField({
scale: 1,
field: {
renderId: field.id.toString(),
...field,
@ -105,10 +105,10 @@ export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSigna
const renderedField = await canvas.toBuffer('svg');
fs.writeFileSync(
`rendered-field-${field.envelopeId}--${field.id}.svg`,
renderedField.toString('utf-8'),
);
// fs.writeFileSync(
// `rendered-field-${field.envelopeId}--${field.id}.svg`,
// renderedField.toString('utf-8'),
// );
// Embed the SVG into the PDF
const svgElement = await pdf.embedSvg(renderedField.toString('utf-8'));

View File

@ -1,36 +0,0 @@
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,
},
});
};

View File

@ -52,7 +52,6 @@ import {
} from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId } from '../envelope/increment-id';
@ -111,8 +110,6 @@ export type CreateDocumentFromTemplateOptions = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
expiryAmount?: number;
expiryUnit?: string;
};
requestMetadata: ApiRequestMetadata;
};
@ -511,16 +508,6 @@ export const createDocumentFromTemplate = async ({
data: finalRecipients.map((recipient) => {
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 {
email: recipient.email,
name: recipient.name,
@ -536,7 +523,6 @@ export const createDocumentFromTemplate = async ({
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
expired: recipientExpiryDate,
token: recipient.token,
};
}),

View File

@ -267,11 +267,6 @@ msgstr "{prefix} hat das Dokument erstellt"
msgid "{prefix} deleted the document"
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
msgid "{prefix} moved the document to team"
msgstr "{prefix} hat das Dokument ins Team verschoben"
@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
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/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Zeitüberschreitung überschritten"
#: 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"
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', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@ -4976,11 +4958,6 @@ msgstr "Link läuft in 1 Stunde ab."
msgid "Link expires in 30 minutes."
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
msgid "Link template"
msgstr "Vorlage verlinken"
@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
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(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@ -5995,10 +5967,6 @@ msgstr "Persönliches Konto"
msgid "Personal Inbox"
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
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@ -6413,10 +6381,6 @@ msgstr "Empfänger"
msgid "Recipient action authentication"
msgstr "Empfängeraktion Authentifizierung"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "E-Mail des entfernten Empfängers"
@ -7131,10 +7095,6 @@ msgstr "Sitzungen wurden widerrufen"
msgid "Set a password"
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
msgid "Set up your document properties and recipient information"
msgstr "Richten Sie Ihre Dokumenteigenschaften und Empfängerinformationen ein"
@ -7433,10 +7393,6 @@ msgstr "Unterzeichne für"
msgid "Signing in..."
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/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@ -8311,10 +8267,6 @@ msgstr "Der Name des Unterzeichners"
msgid "The signing link has been copied to your clipboard."
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
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."
@ -8633,10 +8585,6 @@ msgstr "Diese Sitzung ist abgelaufen. Bitte versuchen Sie es erneut."
msgid "This signer has already signed the document."
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
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."

View File

@ -262,11 +262,6 @@ msgstr "{prefix} created the document"
msgid "{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
msgid "{prefix} moved the document to team"
msgstr "{prefix} moved the document to team"
@ -1705,10 +1700,6 @@ msgctxt "Recipient role progressive verb"
msgid "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/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@ -4080,18 +4071,9 @@ msgid "Exceeded timeout"
msgstr "Exceeded timeout"
#: 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"
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', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@ -4971,11 +4953,6 @@ msgstr "Link expires in 1 hour."
msgid "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
msgid "Link template"
msgstr "Link template"
@ -4997,11 +4974,6 @@ msgstr "Linked At"
msgid "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(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@ -5990,10 +5962,6 @@ msgstr "Personal Account"
msgid "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
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@ -6408,10 +6376,6 @@ msgstr "Recipient"
msgid "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
msgid "Recipient removed email"
msgstr "Recipient removed email"
@ -7126,10 +7090,6 @@ msgstr "Sessions have been revoked"
msgid "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
msgid "Set up your document properties and recipient information"
msgstr "Set up your document properties and recipient information"
@ -7428,10 +7388,6 @@ msgstr "Signing for"
msgid "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/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@ -8316,10 +8272,6 @@ msgstr "The signer's name"
msgid "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
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."
@ -8648,10 +8600,6 @@ msgstr "This session has expired. Please try again."
msgid "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
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."

View File

@ -267,11 +267,6 @@ msgstr "{prefix} creó el documento"
msgid "{prefix} deleted the document"
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
msgid "{prefix} moved the document to team"
msgstr "{prefix} movió el documento al equipo"
@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
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/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Tiempo de espera excedido"
#: 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"
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', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@ -4976,11 +4958,6 @@ msgstr "El enlace expira en 1 hora."
msgid "Link expires in 30 minutes."
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
msgid "Link template"
msgstr "Enlace de plantilla"
@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
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(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@ -5995,10 +5967,6 @@ msgstr "Cuenta personal"
msgid "Personal Inbox"
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
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@ -6413,10 +6381,6 @@ msgstr "Destinatario"
msgid "Recipient action authentication"
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
msgid "Recipient removed email"
msgstr "Correo electrónico de destinatario eliminado"
@ -7131,10 +7095,6 @@ msgstr "Las sesiones han sido revocadas"
msgid "Set a password"
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
msgid "Set up your document properties and recipient information"
msgstr "Configura las propiedades de tu documento y la información del destinatario"
@ -7433,10 +7393,6 @@ msgstr "Firmando para"
msgid "Signing in..."
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/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@ -8311,10 +8267,6 @@ msgstr "El nombre del firmante"
msgid "The signing link has been copied to your clipboard."
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
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."
@ -8635,10 +8587,6 @@ msgstr "Esta sesión ha expirado. Por favor, inténtalo de nuevo."
msgid "This signer has already signed the document."
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
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."

View File

@ -267,11 +267,6 @@ msgstr "{prefix} a créé le document"
msgid "{prefix} deleted the 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
msgid "{prefix} moved the document to team"
msgstr "{prefix} a déplacé le document vers l'équipe"
@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
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/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Délai dépassé"
#: 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"
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', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@ -4976,11 +4958,6 @@ msgstr "Le lien expire dans 1 heure."
msgid "Link expires in 30 minutes."
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
msgid "Link template"
msgstr "Modèle de lien"
@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
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(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@ -5995,10 +5967,6 @@ msgstr "Compte personnel"
msgid "Personal Inbox"
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
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@ -6413,10 +6381,6 @@ msgstr "Destinataire"
msgid "Recipient action authentication"
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
msgid "Recipient removed email"
msgstr "E-mail de destinataire supprimé"
@ -7131,10 +7095,6 @@ msgstr "Les sessions ont été révoquées"
msgid "Set a password"
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
msgid "Set up your document properties and recipient information"
msgstr "Configurez les propriétés de votre document et les informations du destinataire"
@ -7433,10 +7393,6 @@ msgstr "Signé pour"
msgid "Signing in..."
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/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@ -8311,10 +8267,6 @@ msgstr "Le nom du signataire"
msgid "The signing link has been copied to your clipboard."
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
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."
@ -8633,10 +8585,6 @@ msgstr "Cette session a expiré. Veuillez réessayer."
msgid "This signer has already signed the 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
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."

View File

@ -267,11 +267,6 @@ msgstr "{prefix} ha creato il documento"
msgid "{prefix} deleted the document"
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
msgid "{prefix} moved the document to team"
msgstr "{prefix} ha spostato il documento al team"
@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
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/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Tempo scaduto"
#: 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"
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', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@ -4976,11 +4958,6 @@ msgstr "Il link scade tra 1 ora."
msgid "Link expires in 30 minutes."
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
msgid "Link template"
msgstr "Collega modello"
@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
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(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@ -5995,10 +5967,6 @@ msgstr "Account personale"
msgid "Personal Inbox"
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
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@ -6413,10 +6381,6 @@ msgstr "Destinatario"
msgid "Recipient action authentication"
msgstr "Autenticazione azione destinatario"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "Email destinatario rimosso"
@ -7131,10 +7095,6 @@ msgstr "Le sessioni sono state revocate"
msgid "Set a 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
msgid "Set up your document properties and recipient information"
msgstr "Configura le proprietà del documento e le informazioni sui destinatari"
@ -7433,10 +7393,6 @@ msgstr "Firma per"
msgid "Signing in..."
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/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@ -8319,10 +8275,6 @@ msgstr "Il nome del firmatario"
msgid "The signing link has been copied to your clipboard."
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
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."
@ -8649,10 +8601,6 @@ msgstr "Questa sessione è scaduta. Per favore prova di nuovo."
msgid "This signer has already signed the document."
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
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."

View File

@ -267,11 +267,6 @@ msgstr "Użytkownik {prefix} utworzył dokument"
msgid "{prefix} deleted the document"
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
msgid "{prefix} moved the document to team"
msgstr "Użytkownik {prefix} przeniósł dokument do zespołu"
@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
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/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Przekroczono limit czasu"
#: 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"
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', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@ -4976,11 +4958,6 @@ msgstr "Link wygaśnie za 1 godzinę."
msgid "Link expires in 30 minutes."
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
msgid "Link template"
msgstr "Szablon linku"
@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
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(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@ -5995,10 +5967,6 @@ msgstr "Konto osobiste"
msgid "Personal Inbox"
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
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@ -6413,10 +6381,6 @@ msgstr "Odbiorca"
msgid "Recipient action authentication"
msgstr "Uwierzytelnianie odbiorcy"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "Wiadomość o usuniętym odbiorcy"
@ -7131,10 +7095,6 @@ msgstr "Sesje zostały odwołane"
msgid "Set a password"
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
msgid "Set up your document properties and recipient information"
msgstr "Skonfiguruj właściwości dokumentu i informacje o odbiorcach"
@ -7433,10 +7393,6 @@ msgstr "Podpis w imieniu"
msgid "Signing in..."
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/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@ -8311,10 +8267,6 @@ msgstr "Nazwa podpisującego"
msgid "The signing link has been copied to your clipboard."
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
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."
@ -8633,10 +8585,6 @@ msgstr "Ta sesja wygasła. Proszę spróbować ponownie."
msgid "This signer has already signed the document."
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
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."

View File

@ -40,7 +40,6 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_TITLE_UPDATED', // When the document title 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_RECIPIENT_EXPIRY_EXTENDED', // When a recipient's expiry is extended via resend.
// ACCESS AUTH 2FA events.
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
@ -640,20 +639,6 @@ 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({
id: z.string(),
createdAt: z.date(),
@ -695,7 +680,6 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventRecipientExpiryExtendedSchema,
]),
);

View File

@ -107,16 +107,6 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z
.boolean()
.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
* all corresponding areas where this is used (some places that use this needs to pass
@ -137,9 +127,7 @@ export const ZDocumentMetaCreateSchema = z.object({
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
});
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;

View File

@ -69,8 +69,6 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
emailSettings: true,
emailId: true,
emailReplyTo: true,
expiryAmount: true,
expiryUnit: true,
}).extend({
password: z.string().nullable().default(null),
documentId: z.number().default(-1).optional(),

View File

@ -81,6 +81,7 @@ export const ZRadioFieldMeta = ZBaseFieldMeta.extend({
}),
)
.optional(),
direction: z.enum(['vertical', 'horizontal']).optional().default('vertical'),
});
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
@ -278,6 +279,7 @@ export const FIELD_RADIO_META_DEFAULT_VALUES: TRadioFieldMeta = {
values: [{ id: 1, checked: false, value: '' }],
required: false,
readOnly: false,
direction: 'vertical',
};
export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {

View File

@ -12,7 +12,7 @@ export const upsertFieldGroup = (
field: FieldToRender,
options: RenderFieldElementOptions,
): Konva.Group => {
const { pageWidth, pageHeight, pageLayer, editable } = options;
const { pageWidth, pageHeight, pageLayer, editable, scale } = options;
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
field,
@ -27,6 +27,9 @@ export const upsertFieldGroup = (
name: 'field-group',
});
const maxXPosition = (pageWidth - fieldWidth) * scale;
const maxYPosition = (pageHeight - fieldHeight) * scale;
fieldGroup.setAttrs({
scaleX: 1,
scaleY: 1,
@ -34,8 +37,9 @@ export const upsertFieldGroup = (
y: fieldY,
draggable: editable,
dragBoundFunc: (pos) => {
const newX = Math.max(0, Math.min(pageWidth - fieldWidth, pos.x));
const newY = Math.max(0, Math.min(pageHeight - fieldHeight, pos.y));
const newX = Math.max(0, Math.min(maxXPosition, pos.x));
const newY = Math.max(0, Math.min(maxYPosition, pos.y));
return { x: newX, y: newY };
},
} satisfies Partial<Konva.GroupConfig>);

View File

@ -26,8 +26,9 @@ export type RenderFieldElementOptions = {
pageLayer: Konva.Layer;
pageWidth: number;
pageHeight: number;
mode?: 'edit' | 'sign' | 'export';
mode: 'edit' | 'sign' | 'export';
editable?: boolean;
scale: number;
color?: TRecipientColor;
};
@ -107,6 +108,11 @@ type CalculateMultiItemPositionOptions = {
*/
fieldPadding: number;
/**
* The direction of the items.
*/
direction: 'horizontal' | 'vertical';
type: 'checkbox' | 'radio';
};
@ -122,6 +128,7 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
itemSize,
spacingBetweenItemAndText,
fieldPadding,
direction,
type,
} = options;
@ -130,6 +137,39 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
const innerFieldX = 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 y = itemIndex * itemHeight + innerFieldY;
@ -137,6 +177,7 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
let itemInputY = y + itemHeight / 2 - itemSize / 2;
let itemInputX = innerFieldX;
// We need a little different logic to center the radio circle icon.
if (type === 'radio') {
itemInputX = innerFieldX + itemSize / 2;
itemInputY = y + itemHeight / 2;

View File

@ -1,4 +1,5 @@
import Konva from 'konva';
import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TCheckboxFieldMeta } from '../../types/field-meta';
@ -21,104 +22,112 @@ export const renderCheckboxFieldElement = (
const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children to re-render fresh
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
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 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);
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 } =
calculateMultiItemPosition({
fieldWidth,
@ -128,6 +137,7 @@ export const renderCheckboxFieldElement = (
itemSize: checkboxSize,
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
direction: checkboxMeta?.direction || 'vertical',
type: 'checkbox',
});
@ -156,7 +166,7 @@ export const renderCheckboxFieldElement = (
strokeWidth: 2,
stroke: '#111827',
points: [3, 8, 7, 12, 13, 4],
visible: checked,
visible: isCheckboxChecked,
});
const text = new Konva.Text({

View File

@ -47,6 +47,7 @@ type RenderFieldOptions = {
*/
mode: 'edit' | 'sign' | 'export';
scale: number;
editable?: boolean;
};
@ -56,6 +57,7 @@ export const renderField = ({
pageWidth,
pageHeight,
mode,
scale,
editable,
color,
}: RenderFieldOptions) => {
@ -66,6 +68,7 @@ export const renderField = ({
mode,
color,
editable,
scale,
};
return match(field.type)

View File

@ -1,4 +1,5 @@
import Konva from 'konva';
import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TRadioFieldMeta } from '../../types/field-meta';
@ -26,90 +27,99 @@ export const renderRadioFieldElement = (
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;
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
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 groupedItems = circles.map((circle, i) => ({
circleElement: circle,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { circleElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: radioValues.length,
itemIndex: i,
itemSize: radioSize,
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
});
circleElement.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.width(rectWidth);
fieldRect.height(rectHeight);
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
}
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
const radioValues = radioMeta?.values || [];
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
fieldGroup.off('transform');
// 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;
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
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 groupedItems = circles.map((circle, i) => ({
circleElement: circle,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { circleElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: radioValues.length,
itemIndex: i,
itemSize: radioSize,
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
direction: radioMeta?.direction || 'vertical',
});
circleElement.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.width(rectWidth);
fieldRect.height(rectHeight);
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
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 } =
calculateMultiItemPosition({
fieldWidth,
@ -120,6 +130,7 @@ export const renderRadioFieldElement = (
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
direction: radioMeta?.direction || 'vertical',
});
// Circle which represents the radio button.
@ -144,9 +155,7 @@ export const renderRadioFieldElement = (
y: itemInputY,
radius: radioSize / 4,
fill: '#111827',
// Todo: Envelopes
visible: value === field.customText,
// visible: checked,
visible: isRadioValueChecked,
});
const text = new Konva.Text({

View File

@ -96,77 +96,80 @@ export const renderSignatureFieldElement = (
const fieldGroup = upsertFieldGroup(field, options);
// ABOVE IS GENERIC, EXTRACT IT.
// Clear previous children and listeners to re-render fresh.
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.
const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options);
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
pageLayer.add(fieldGroup);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
// 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.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// 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.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
console.log({
rectWidth,
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
console.log({
rectWidth,
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
}
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
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.
if (mode === 'export') {

View File

@ -121,77 +121,80 @@ export const renderTextFieldElement = (
const fieldGroup = upsertFieldGroup(field, options);
// ABOVE IS GENERIC, EXTRACT IT.
// Clear previous children and listeners to re-render fresh.
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.
const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options);
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
pageLayer.add(fieldGroup);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
// 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.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// 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.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
console.log({
rectWidth,
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
console.log({
rectWidth,
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
}
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
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.
if (mode === 'export') {

View File

@ -515,10 +515,6 @@ export const formatDocumentAuditLogAction = (
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();
return {

View File

@ -20,26 +20,6 @@ export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | Documen
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
* from scratch, or from a template.
@ -82,10 +62,6 @@ export const extractDerivedDocumentMeta = (
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
emailSettings:
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
// Expiry settings.
expiryAmount: getExpiryAmount(meta),
expiryUnit: getExpiryUnit(meta),
} satisfies Omit<DocumentMeta, 'id'>;
};

View File

@ -1,72 +0,0 @@
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');
};

View File

@ -1,6 +0,0 @@
-- AlterEnum
ALTER TYPE "SigningStatus" ADD VALUE 'EXPIRED';
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "expiryAmount" INTEGER,
ADD COLUMN "expiryUnit" TEXT;

View File

@ -505,9 +505,6 @@ model DocumentMeta {
emailReplyTo String?
emailId String?
expiryAmount Int?
expiryUnit String?
envelope Envelope?
}
@ -525,7 +522,6 @@ enum SigningStatus {
NOT_SIGNED
SIGNED
REJECTED
EXPIRED
}
enum RecipientRole {

View File

@ -6,7 +6,6 @@ import { createDocumentData } from '@documenso/lib/server-only/document-data/cre
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
@ -38,17 +37,8 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
recipients,
meta,
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.',
});
}
const { remaining } = await getServerLimits({ userId: user.id, teamId });
if (remaining.documents <= 0) {
@ -96,12 +86,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
},
],
},
meta: {
...meta,
emailSettings: meta?.emailSettings ?? undefined,
expiryAmount,
expiryUnit,
},
meta,
requestMetadata: ctx.metadata,
});

View File

@ -19,8 +19,6 @@ import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
ZDocumentExternalIdSchema,
ZDocumentTitleSchema,
ZDocumentVisibilitySchema,
@ -53,8 +51,6 @@ 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.',
)
.optional(),
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
recipients: z
.array(
ZCreateRecipientSchema.extend({

View File

@ -4,7 +4,6 @@ import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
import { authenticatedProcedure } from '../trpc';
import {
@ -17,14 +16,7 @@ export const createDocumentRoute = authenticatedProcedure
.output(ZCreateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx;
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.',
});
}
const { title, documentDataId, timezone, folderId } = input;
ctx.logger.info({
input: {
@ -56,10 +48,6 @@ export const createDocumentRoute = authenticatedProcedure
},
],
},
meta: {
expiryAmount,
expiryUnit,
},
normalizePdf: true,
requestMetadata: ctx.metadata,
});

View File

@ -1,10 +1,6 @@
import { z } from 'zod';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { ZDocumentTitleSchema } from './schema';
@ -23,8 +19,6 @@ export const ZCreateDocumentRequestSchema = z.object({
documentDataId: z.string().min(1),
timezone: ZDocumentMetaTimezoneSchema.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({

View File

@ -37,7 +37,7 @@ export const distributeDocumentRoute = authenticatedProcedure
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
emailSettings: meta.emailSettings ?? undefined,
emailSettings: meta.emailSettings,
language: meta.language,
emailId: meta.emailId,
emailReplyTo: meta.emailReplyTo,

View File

@ -1,11 +1,6 @@
import { DocumentVisibility } from '@prisma/client';
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.
*
@ -35,6 +30,3 @@ export const ZDocumentExternalIdSchema = z
export const ZDocumentVisibilitySchema = z
.nativeEnum(DocumentVisibility)
.describe('The visibility of the document.');
// Re-export expiry schemas for convenience
export { ZDocumentExpiryAmountSchema, ZDocumentExpiryUnitSchema };

View File

@ -1,7 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
import { authenticatedProcedure } from '../trpc';
import {
@ -29,15 +27,6 @@ export const updateDocumentRoute = authenticatedProcedure
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({
userId,
teamId,

View File

@ -35,7 +35,7 @@ export const distributeEnvelopeRoute = authenticatedProcedure
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
emailSettings: meta.emailSettings ?? undefined,
emailSettings: meta.emailSettings,
language: meta.language,
emailId: meta.emailId,
emailReplyTo: meta.emailReplyTo,

View File

@ -91,11 +91,11 @@ export const PdfViewerKonva = ({
}, []);
return (
<div ref={$el} className={cn('w-[800px] overflow-hidden', className)} {...props}>
<div ref={$el} className={cn('w-full max-w-[800px]', className)} {...props}>
{envelopeItemFile && Konva ? (
<PDFDocument
file={envelopeItemFile}
className={cn('w-full overflow-hidden rounded', {
className={cn('w-full rounded', {
'h-[80vh] max-h-[60rem]': numPages === 0,
})}
onLoadSuccess={(d) => onDocumentLoaded(d)}
@ -138,7 +138,7 @@ export const PdfViewerKonva = ({
.fill(null)
.map((_, i) => (
<div key={i} className="last:-mb-2">
<div className="border-border overflow-hidden rounded border will-change-transform">
<div className="border-border rounded border will-change-transform">
<PDFPage
pageNumber={i + 1}
width={width}

View File

@ -9,6 +9,7 @@ export type RecipientColorStyles = {
base: string;
baseRing: string;
baseRingHover: string;
fieldButton: string;
fieldItem: string;
fieldItemInitials: string;
comboxBoxTrigger: string;
@ -23,6 +24,7 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-neutral-400',
baseRing: '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]',
fieldItemInitials: '',
comboxBoxTrigger:
@ -34,6 +36,7 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-green hover:bg-recipient-green/30',
baseRing: 'rgba(122, 195, 85, 1)',
baseRingHover: 'rgba(122, 195, 85, 0.3)',
fieldButton: 'hover:border-recipient-green hover:bg-recipient-green/30 ',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-green',
comboxBoxTrigger:
@ -45,6 +48,7 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-blue hover:bg-recipient-blue/30',
baseRing: 'rgba(56, 123, 199, 1)',
baseRingHover: 'rgba(56, 123, 199, 0.3)',
fieldButton: 'hover:border-recipient-blue hover:bg-recipient-blue/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-blue',
comboxBoxTrigger:
@ -56,6 +60,7 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-purple hover:bg-recipient-purple/30',
baseRing: 'rgba(151, 71, 255, 1)',
baseRingHover: 'rgba(151, 71, 255, 0.3)',
fieldButton: 'hover:border-recipient-purple hover:bg-recipient-purple/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-purple',
comboxBoxTrigger:
@ -67,6 +72,7 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-orange hover:bg-recipient-orange/30',
baseRing: 'rgba(246, 159, 30, 1)',
baseRingHover: 'rgba(246, 159, 30, 0.3)',
fieldButton: 'hover:border-recipient-orange hover:bg-recipient-orange/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-orange',
comboxBoxTrigger:
@ -78,6 +84,7 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-yellow hover:bg-recipient-yellow/30',
baseRing: 'rgba(219, 186, 0, 1)',
baseRingHover: 'rgba(219, 186, 0, 0.3)',
fieldButton: 'hover:border-recipient-yellow hover:bg-recipient-yellow/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-yellow',
comboxBoxTrigger:
@ -89,6 +96,7 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-pink hover:bg-recipient-pink/30',
baseRing: 'rgba(217, 74, 186, 1)',
baseRingHover: 'rgba(217, 74, 186, 0.3)',
fieldButton: 'hover:border-recipient-pink hover:bg-recipient-pink/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-pink',
comboxBoxTrigger:

View File

@ -1,131 +0,0 @@
'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>
);
};

View File

@ -11,7 +11,7 @@ import {
TeamMemberRole,
} from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
@ -57,7 +57,6 @@ import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combo
import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip';
import { Combobox } from '../combobox';
import { ExpirySettingsPicker } from '../expiry-settings-picker';
import { Input } from '../input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper';
@ -73,18 +72,6 @@ import {
} from './document-flow-root';
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 = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
@ -114,9 +101,6 @@ export const AddSettingsFormPartial = ({
documentAuth: document.authOptions,
});
const documentExpiryUnit = document.documentMeta?.expiryUnit;
const initialExpiryUnit = isExpiryUnit(documentExpiryUnit) ? documentExpiryUnit : undefined;
const form = useForm<TAddSettingsFormSchema>({
resolver: zodResolver(ZAddSettingsFormSchema),
defaultValues: {
@ -136,8 +120,6 @@ export const AddSettingsFormPartial = ({
redirectUrl: document.documentMeta?.redirectUrl ?? '',
language: document.documentMeta?.language ?? 'en',
signatureTypes: extractTeamSignatureSettings(document.documentMeta),
expiryAmount: document.documentMeta?.expiryAmount ?? undefined,
expiryUnit: initialExpiryUnit,
},
},
});
@ -148,9 +130,6 @@ export const AddSettingsFormPartial = ({
(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)
.with(TeamMemberRole.ADMIN, () => true)
.with(
@ -543,33 +522,6 @@ export const AddSettingsFormPartial = ({
</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>
</AccordionContent>
</AccordionItem>

View File

@ -46,8 +46,6 @@ export const ZAddSettingsFormSchema = z.object({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
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(),
}),
});

View File

@ -63,8 +63,6 @@ export type AddSignersFormProps = {
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
expiryAmount?: number | null;
expiryUnit?: 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | null;
onSubmit: (_data: TAddSignersFormSchema) => void;
onAutoSave: (_data: TAddSignersFormSchema) => Promise<AutoSaveResponse>;
isDocumentPdfLoaded: boolean;
@ -76,8 +74,6 @@ export const AddSignersFormPartial = ({
fields,
signingOrder,
allowDictateNextSigner,
expiryAmount,
expiryUnit,
onSubmit,
onAutoSave,
isDocumentPdfLoaded,
@ -142,10 +138,6 @@ export const AddSignersFormPartial = ({
: defaultRecipients,
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: allowDictateNextSigner ?? false,
meta: {
expiryAmount: expiryAmount ?? undefined,
expiryUnit: expiryUnit ?? undefined,
},
},
});

View File

@ -3,10 +3,6 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
} from '@documenso/lib/types/document-meta';
export const ZAddSignersFormSchema = z.object({
signers: z.array(
@ -25,10 +21,6 @@ export const ZAddSignersFormSchema = z.object({
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
meta: z.object({
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
}),
});
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;

View File

@ -1,79 +0,0 @@
'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>
);
};

View File

@ -1,132 +0,0 @@
'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>
);
};