Compare commits

...

23 Commits

Author SHA1 Message Date
David Nguyen 072b4ea7db fix: wip 2025-04-24 10:41:23 +10:00
David Nguyen 193325717d fix: rework fields (#1697)
Rework:
- Field styling to improve visibility
- Field insertions, better alignment, centering and overflows

## Changes

General changes:

- Set default text alignment to left if no meta found
- Reduce borders and rings around fields to allow smaller fields
- Removed lots of redundant duplicated code surrounding field rendering
- Make fields more consistent across viewing, editing and signing
- Add more transparency to fields to allow users to see under fields
- No more optional/required/etc colors when signing, required fields
will be highlighted as orange when form is "validating"

Highlighted internal changes:

- Utilize native PDF fields to insert text, instead of drawing text 
- Change font auto scaling to only apply to when the height overflows
AND no custom font is set

⚠️ Multiline changes:

Multi line is enabled for a field under these conditions

1. Field content exceeds field width
2. Field includes a new line
3. Field type is TEXT

## [BEFORE] Field UI Signing 


![image](https://github.com/user-attachments/assets/ea002743-faeb-477c-a239-3ed240b54f55)

## [AFTER] Field UI Signing 


![image](https://github.com/user-attachments/assets/0f8eb252-4cf3-4d96-8d4f-cd085881b78c)

## [BEFORE] Signing a checkbox


![image](https://github.com/user-attachments/assets/4567d745-e1da-4202-a758-5d3c178c930e)

![image](https://github.com/user-attachments/assets/c25068e7-fe80-40f5-b63a-e8a0d4b38b6c)

## [AFTER] Signing a checkbox


![image](https://github.com/user-attachments/assets/effa5e3d-385a-4c35-bc8a-405386dd27d6)

![image](https://github.com/user-attachments/assets/64be34a9-0b32-424d-9264-15361c03eca5)

## [BEFORE] What a 2nd recipient sees once someone else signed a
document


![image](https://github.com/user-attachments/assets/21c21ae2-fc62-4ccc-880a-46aab012aa70)

## [AFTER] What a 2nd recipient sees once someone else signed a document


![image](https://github.com/user-attachments/assets/ae51677b-f1d5-4008-a7fd-756533166542)

## **[BEFORE]** Inserting fields


![image](https://github.com/user-attachments/assets/1a8bb8da-9a15-4deb-bc28-eb349414465c)

## **[AFTER]** Inserting fields


![image](https://github.com/user-attachments/assets/c52c5238-9836-45aa-b8a4-bc24a3462f40)

## Overflows, multilines and field alignments testing

Debugging borders:
- Red border = The original field placement without any modifications
- Blue border = The available space to overflow

### Single line overflows and field alignments 

This is left aligned fields, overflow will always go to the end of the
page and will not wrap


![image](https://github.com/user-attachments/assets/47003658-783e-4f9c-adbf-c4686804d98f)

This is center aligned fields, the max width is the closest edge to the
page * 2


![image](https://github.com/user-attachments/assets/05a38093-75d6-4600-bae2-21ecff63e115)

This is right aligned text, the width will extend all the way to the
left hand side of the page


![image](https://github.com/user-attachments/assets/6a9d84a8-4166-4626-9fb3-1577fac2571e)

### Multiline line overflows and field alignments 

These are text fields that can be overflowed


![image](https://github.com/user-attachments/assets/f7b5456e-2c49-48b2-8d4c-ab1dc3401644)

Another example of left aligned text overflows with more text


![image](https://github.com/user-attachments/assets/3db6b35e-4c8d-4ffe-8036-0da760d9c167)
2025-04-23 21:40:42 +10:00
Catalin Pit b94645a451 fix: optional fields being required in direct links (#1752) 2025-04-21 16:34:29 +10:00
Mythie 7e6704faae chore: update tests 2025-04-21 16:23:50 +10:00
Mythie cf17fc61bc chore: update tests 2025-04-21 16:07:19 +10:00
Mythie 6df8b3aac8 chore: update ci 2025-04-21 14:29:40 +10:00
Mythie fdb31772db chore: update tests 2025-04-21 14:13:12 +10:00
Mythie a3dfd81870 chore: update playwright config 2025-04-21 13:27:19 +10:00
Mythie 755ef697ba chore: update playwright config 2025-04-21 13:03:29 +10:00
Mythie 37cc41d713 fix: skip immediate expiration presign test 2025-04-21 12:41:38 +10:00
Mythie dd2ef3a657 v1.10.0-rc.5 2025-04-17 23:01:43 +10:00
David Nguyen 435b3ca4f8 chore: remove legacy document update route (#1751)
Remove deprecated route
2025-04-17 16:36:10 +10:00
Mythie 278cd8a9de fix: always show ip and useragent in certificate 2025-04-17 12:55:03 +10:00
Catalin Pit f1526315f5 feat: limit free teams platform plan (#1673)
This pull request removes the `id` field from
`IsDocumentPlatformOptions` in `is-document-platform.ts` and updates the
billing logic in `create-team.ts`: platform plan users create their
first team free, but pay for subsequent teams; non-platform users need
an active team subscription if billing is enabled.
2025-04-15 21:32:15 +10:00
RefRexi 353a7e8e0d fix: dynamic route for team transfer (#1730)
fix: dynamic route handling for /team/verify/transfer/:token
2025-04-15 21:30:44 +10:00
Ephraim Duncan 34b2504268 chore: husky (#1706) 2025-04-15 21:29:03 +10:00
Catalin Pit 566abda36b chore: update render build command (#1748) 2025-04-15 19:06:06 +10:00
Mythie 9121a062b3 chore: add docs for authoring 2025-04-14 11:31:54 +10:00
Lucas Smith e613e0e347 feat: support embedded authoring for creation (#1741)
Adds support for creating documents and templates
using our embed components.

Support is super primitive at the moment and is being polished.
2025-04-11 00:20:39 +10:00
Lucas Smith 95aae52fa4 chore: add translations (#1715)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-04-10 12:24:07 +10:00
Ephraim Duncan 5958f38719 chore: set the default value on the top (#1734) 2025-04-08 23:35:32 +10:00
Catalin Pit 419bc02171 docs: prefill fields (#1688) 2025-04-04 00:03:37 +11:00
Ephraim Duncan 5e4956f3a2 fix: zero month addition (#1733)
- Add zero month at the begining of each metric on the open page
2025-04-01 11:12:41 +00:00
151 changed files with 21349 additions and 1983 deletions
+3
View File
@@ -31,6 +31,9 @@ jobs:
- name: Build app
run: npm run build
- name: Install playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npm run ci
env:
-3
View File
@@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run commitlint -- $1
-3
View File
@@ -1,6 +1,3 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
@@ -6,5 +6,6 @@
"solid": "Solid Integration",
"preact": "Preact Integration",
"angular": "Angular Integration",
"css-variables": "CSS Variables"
}
"css-variables": "CSS Variables",
"authoring": "Authoring"
}
@@ -0,0 +1,167 @@
---
title: Authoring
description: Learn how to use embedded authoring to create documents and templates in your application
---
# Embedded Authoring
In addition to embedding signing experiences, Documenso now supports embedded authoring, allowing you to integrate document and template creation directly within your application.
## How Embedded Authoring Works
The embedded authoring feature enables your users to create new documents without leaving your application. This process works through secure presign tokens that authenticate the embedding session and manage permissions.
## Creating Documents with Embedded Authoring
To implement document creation in your application, use the `EmbedCreateDocument` component from our SDK:
```jsx
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
const DocumentCreator = () => {
// You'll need to obtain a presign token using your API key
const presignToken = 'YOUR_PRESIGN_TOKEN';
return (
<div style={{ height: '800px', width: '100%' }}>
<EmbedCreateDocument
presignToken={presignToken}
externalId="order-12345"
onDocumentCreated={(data) => {
console.log('Document created with ID:', data.documentId);
console.log('External reference ID:', data.externalId);
}}
/>
</div>
);
};
```
## Obtaining a Presign Token
Before using the `EmbedCreateDocument` component, you'll need to obtain a presign token from your backend. This token authorizes the embedding session.
You can create a presign token by making a request to:
```
POST /api/v2-beta/embedding/create-presign-token
```
This API endpoint requires authentication with your Documenso API key. The token has a default expiration of 1 hour, but you can customize this duration based on your security requirements.
You can find more details on this request at our [API Documentation](https://openapi.documenso.com/reference#tag/embedding)
## Configuration Options
The `EmbedCreateDocument` component accepts several configuration options:
| Option | Type | Description |
| ------------------ | ------- | ------------------------------------------------------------------ |
| `presignToken` | string | **Required**. The authentication token for the embedding session. |
| `externalId` | string | Optional reference ID from your system to link with the document. |
| `host` | string | Optional custom host URL. Defaults to `https://app.documenso.com`. |
| `css` | string | Optional custom CSS to style the embedded component. |
| `cssVars` | object | Optional CSS variables for colors, spacing, and more. |
| `darkModeDisabled` | boolean | Optional flag to disable dark mode. |
| `className` | string | Optional CSS class name for the iframe. |
## Feature Toggles
You can customize the authoring experience by enabling or disabling specific features:
```jsx
<EmbedCreateDocument
presignToken="YOUR_PRESIGN_TOKEN"
features={{
allowConfigureSignatureTypes: true,
allowConfigureLanguage: true,
allowConfigureDateFormat: true,
allowConfigureTimezone: true,
allowConfigureRedirectUrl: true,
allowConfigureCommunication: true,
}}
/>
```
## Handling Document Creation Events
The `onDocumentCreated` callback is triggered when a document is successfully created, providing both the document ID and your external reference ID:
```jsx
<EmbedCreateDocument
presignToken="YOUR_PRESIGN_TOKEN"
externalId="order-12345"
onDocumentCreated={(data) => {
// Navigate to a success page
navigate(`/documents/success?id=${data.documentId}`);
// Or update your database with the document ID
updateOrderDocument(data.externalId, data.documentId);
}}
/>
```
## Styling the Embedded Component
You can customize the appearance of the embedded component using standard CSS classes:
```jsx
<EmbedCreateDocument
className="h-screen w-full rounded-lg border-none shadow-md"
presignToken="YOUR_PRESIGN_TOKEN"
css={`
.documenso-embed {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
`}
cssVars={{
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
}}
/>
```
## Complete Integration Example
Here's a complete example of integrating document creation in a React application:
```tsx
import { useState } from 'react';
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
function DocumentCreator() {
// In a real application, you would fetch this token from your backend
// using your API key at /api/v2-beta/embedding/create-presign-token
const presignToken = 'YOUR_PRESIGN_TOKEN';
const [documentId, setDocumentId] = useState<number | null>(null);
if (documentId) {
return (
<div>
<h2>Document Created Successfully!</h2>
<p>Document ID: {documentId}</p>
<button onClick={() => setDocumentId(null)}>Create Another Document</button>
</div>
);
}
return (
<div style={{ height: '800px', width: '100%' }}>
<EmbedCreateDocument
presignToken={presignToken}
externalId="order-12345"
onDocumentCreated={(data) => {
setDocumentId(data.documentId);
}}
/>
</div>
);
}
export default DocumentCreator;
```
With embedded authoring, your users can seamlessly create documents within your application, enhancing the overall user experience and streamlining document workflows.
@@ -169,6 +169,19 @@ Once you've obtained the appropriate tokens, you can integrate the signing exper
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
## Embedded Authoring
In addition to embedding signing experiences, Documenso now supports **embedded authoring**, allowing your users to create documents and templates directly within your application.
With embedded authoring, you can:
- Create new documents with custom fields
- Configure document properties and settings
- Set up recipients and signing workflows
- Customize the authoring experience
For detailed implementation instructions and code examples, see our [Embedded Authoring](/developers/embedding/authoring) guide.
## Related
- [React Integration](/developers/embedding/react)
@@ -178,3 +191,4 @@ If you're using **web components**, the integration process is slightly differen
- [Preact Integration](/developers/embedding/preact)
- [Angular Integration](/developers/embedding/angular)
- [CSS Variables](/developers/embedding/css-variables)
- [Embedded Authoring](/developers/embedding/authoring)
@@ -532,3 +532,93 @@ Replace the `text` value with the corresponding field type:
- For the `SELECT` field it should be `select`. (check this before merge)
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.
## Pre-fill Fields On Document Creation
The API allows you to pre-fill fields on document creation. This is useful when you want to create a document from an existing template and pre-fill the fields with specific values.
To pre-fill a field, you need to make a `POST` request to the `/api/v1/templates/{templateId}/generate-document` endpoint with the field information. Here's an example:
```json
{
"title": "my-document.pdf",
"recipients": [
{
"id": 3,
"name": "Example User",
"email": "example@documenso.com",
"signingOrder": 1,
"role": "SIGNER"
}
],
"prefillFields": [
{
"id": 21,
"type": "text",
"label": "my-label",
"placeholder": "my-placeholder",
"value": "my-value"
},
{
"id": 22,
"type": "number",
"label": "my-label",
"placeholder": "my-placeholder",
"value": "123"
},
{
"id": 23,
"type": "checkbox",
"label": "my-label",
"placeholder": "my-placeholder",
"value": ["option-1", "option-2"]
}
]
}
```
Check out the endpoint in the [API V1 documentation](https://app.documenso.com/api/v1/openapi#:~:text=/%7BtemplateId%7D/-,generate,-%2Ddocument).
### API V2
For API V2, you need to make a `POST` request to the `/api/v2-beta/template/use` endpoint with the field(s) information. Here's an example:
```json
{
"templateId": 111,
"recipients": [
{
"id": 3,
"name": "Example User",
"email": "example@documenso.com",
"signingOrder": 1,
"role": "SIGNER"
}
],
"prefillFields": [
{
"id": 21,
"type": "text",
"label": "my-label",
"placeholder": "my-placeholder",
"value": "my-value"
},
{
"id": 22,
"type": "number",
"label": "my-label",
"placeholder": "my-placeholder",
"value": "123"
},
{
"id": 23,
"type": "checkbox",
"label": "my-label",
"placeholder": "my-placeholder",
"value": ["option-1", "option-2"]
}
]
}
```
Check out the endpoint in the [API V2 documentation](https://openapi.documenso.com/reference#tag/template/POST/template/use).
@@ -1,5 +1,3 @@
'use client';
import React from 'react';
import NextPlausibleProvider from 'next-plausible';
+54
View File
@@ -0,0 +1,54 @@
import { DateTime } from 'luxon';
export interface TransformedData {
labels: string[];
datasets: Array<{
label: string;
data: number[];
}>;
}
export function addZeroMonth(transformedData: TransformedData): TransformedData {
const result = {
labels: [...transformedData.labels],
datasets: transformedData.datasets.map((dataset) => ({
label: dataset.label,
data: [...dataset.data],
})),
};
if (result.labels.length === 0) {
return result;
}
if (result.datasets.every((dataset) => dataset.data[0] === 0)) {
return result;
}
try {
let firstMonth = DateTime.fromFormat(result.labels[0], 'MMM yyyy');
if (!firstMonth.isValid) {
const formats = ['MMM yyyy', 'MMMM yyyy', 'MM/yyyy', 'yyyy-MM'];
for (const format of formats) {
firstMonth = DateTime.fromFormat(result.labels[0], format);
if (firstMonth.isValid) break;
}
if (!firstMonth.isValid) {
console.warn(`Could not parse date: "${result.labels[0]}"`);
return transformedData;
}
}
const zeroMonth = firstMonth.minus({ months: 1 }).toFormat('MMM yyyy');
result.labels.unshift(zeroMonth);
result.datasets.forEach((dataset) => {
dataset.data.unshift(0);
});
return result;
} catch (error) {
return transformedData;
}
}
@@ -3,6 +3,8 @@ import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { addZeroMonth } from '../add-zero-month';
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
const qb = kyselyPrisma.$kysely
.selectFrom('Document')
@@ -35,7 +37,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
],
};
return transformedData;
return addZeroMonth(transformedData);
};
export type GetCompletedDocumentsMonthlyResult = Awaited<
@@ -2,6 +2,8 @@ import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { addZeroMonth } from '../add-zero-month';
export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = 'count') => {
const qb = kyselyPrisma.$kysely
.selectFrom('Recipient')
@@ -34,7 +36,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
],
};
return transformedData;
return addZeroMonth(transformedData);
};
export type GetSignerConversionMonthlyResult = Awaited<
@@ -2,6 +2,8 @@ import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { addZeroMonth } from '../add-zero-month';
export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count') => {
const qb = kyselyPrisma.$kysely
.selectFrom('User')
@@ -32,7 +34,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
],
};
return transformedData;
return addZeroMonth(transformedData);
};
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
+69 -21
View File
@@ -1,5 +1,7 @@
import { DateTime } from 'luxon';
import { addZeroMonth } from './add-zero-month';
type MetricKeys = {
stars: number;
forks: number;
@@ -37,31 +39,77 @@ export function transformData({
data: DataEntry;
metric: MetricKey;
}): TransformData {
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
const [yearA, monthA] = dateA.split('-').map(Number);
const [yearB, monthB] = dateB.split('-').map(Number);
try {
if (!data || Object.keys(data).length === 0) {
return {
labels: [],
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
};
}
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
});
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
try {
const [yearA, monthA] = dateA.split('-').map(Number);
const [yearB, monthB] = dateB.split('-').map(Number);
const labels = sortedEntries.map(([date]) => {
const [year, month] = date.split('-');
const dateTime = DateTime.fromObject({
year: Number(year),
month: Number(month),
if (isNaN(yearA) || isNaN(monthA) || isNaN(yearB) || isNaN(monthB)) {
console.warn(`Invalid date format: ${dateA} or ${dateB}`);
return 0;
}
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
} catch (error) {
console.error('Error sorting entries:', error);
return 0;
}
});
return dateTime.toFormat('MMM yyyy');
});
return {
labels,
datasets: [
{
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
data: sortedEntries.map(([_, stats]) => stats[metric]),
},
],
};
const labels = sortedEntries.map(([date]) => {
try {
const [year, month] = date.split('-');
if (!year || !month || isNaN(Number(year)) || isNaN(Number(month))) {
console.warn(`Invalid date format: ${date}`);
return date;
}
const dateTime = DateTime.fromObject({
year: Number(year),
month: Number(month),
});
if (!dateTime.isValid) {
console.warn(`Invalid DateTime object for: ${date}`);
return date;
}
return dateTime.toFormat('MMM yyyy');
} catch (error) {
console.error('Error formatting date:', error, date);
return date;
}
});
const transformedData = {
labels,
datasets: [
{
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
data: sortedEntries.map(([_, stats]) => {
const value = stats[metric];
return typeof value === 'number' && !isNaN(value) ? value : 0;
}),
},
],
};
return addZeroMonth(transformedData);
} catch (error) {
return {
labels: [],
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
};
}
}
// To be on the safer side
@@ -1,3 +1,5 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
@@ -55,6 +57,8 @@ export function AssistantConfirmationDialog({
allowDictateNextSigner = false,
defaultNextSigner,
}: ConfirmationDialogProps) {
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
resolver: zodResolver(ZNextSignerFormSchema),
defaultValues: {
@@ -107,53 +111,72 @@ export function AssistantConfirmationDialog({
<div className="mt-4 flex flex-col gap-4">
{allowDictateNextSigner && (
<div className="my-2">
<p className="text-muted-foreground mb-1 text-sm font-semibold">
The next recipient to sign this document will be{' '}
</p>
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<div className="flex flex-col gap-4 rounded-xl border p-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
@@ -114,7 +114,7 @@ export const TemplateBulkSendDialog = ({
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button>
<Button variant="outline">
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</Button>
@@ -0,0 +1,355 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentDistributionMethod } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import type { Control } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
interface ConfigureDocumentAdvancedSettingsProps {
control: Control<TConfigureEmbedFormSchema>;
isSubmitting: boolean;
}
export const ConfigureDocumentAdvancedSettings = ({
control,
isSubmitting,
}: ConfigureDocumentAdvancedSettingsProps) => {
const { _ } = useLingui();
const form = useFormContext<TConfigureEmbedFormSchema>();
const { features } = useConfigureDocument();
const { watch, setValue } = form;
// Lift watch() calls to reduce re-renders
const distributionMethod = watch('meta.distributionMethod');
const emailSettings = watch('meta.emailSettings');
const isEmailDistribution = distributionMethod === DocumentDistributionMethod.EMAIL;
return (
<div>
<h3 className="text-foreground mb-1 text-lg font-medium">
<Trans>Advanced Settings</Trans>
</h3>
<p className="text-muted-foreground mb-6 text-sm">
<Trans>Configure additional options and preferences</Trans>
</p>
<Tabs defaultValue="general">
<TabsList className="mb-6 inline-flex">
<TabsTrigger value="general" className="px-4">
<Trans>General</Trans>
</TabsTrigger>
{features.allowConfigureCommunication && (
<TabsTrigger value="communication" className="px-4">
<Trans>Communication</Trans>
</TabsTrigger>
)}
</TabsList>
<TabsContent value="general" className="mt-0">
<div className="flex flex-col space-y-6">
{/* <FormField
control={control}
name="meta.externalId"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>External ID</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
Add an external ID to the document. This can be used to identify the
document in external systems.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} disabled={isSubmitting} />
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
{features.allowConfigureSignatureTypes && (
<FormField
control={control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Allowed Signature Types</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: _(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{features.allowConfigureLanguage && (
<FormField
control={control}
name="meta.language"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Language</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{features.allowConfigureDateFormat && (
<FormField
control={control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date Format</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{features.allowConfigureTimezone && (
<FormField
control={control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
{...field}
onChange={(value) => value && field.onChange(value)}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{features.allowConfigureRedirectUrl && (
<FormField
control={control}
name="meta.redirectUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Redirect URL</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
Add a URL to redirect the user to once the document is signed
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} disabled={isSubmitting} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</TabsContent>
{features.allowConfigureCommunication && (
<TabsContent value="communication" className="mt-0">
<div className="flex flex-col space-y-6">
<FormField
control={control}
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Distribution Method</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DocumentDistributionMethod.EMAIL}>
<Trans>Email</Trans>
</SelectItem>
<SelectItem value={DocumentDistributionMethod.NONE}>
<Trans>None</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Choose how to distribute your document to recipients. Email will send
notifications, None will generate signing links for manual distribution.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<fieldset
className="flex flex-col space-y-6 disabled:cursor-not-allowed disabled:opacity-60"
disabled={!isEmailDistribution}
>
<FormField
control={control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="subject">
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input
id="subject"
className="bg-background mt-2"
disabled={isSubmitting || !isEmailDistribution}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="message">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Textarea
id="message"
className="bg-background mt-2 h-32 resize-none"
disabled={isSubmitting || !isEmailDistribution}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentSendEmailMessageHelper />
<DocumentEmailCheckboxes
className={`mt-2 ${!isEmailDistribution ? 'pointer-events-none' : ''}`}
value={emailSettings}
onChange={(value) => setValue('meta.emailSettings', value)}
/>
</fieldset>
</div>
</TabsContent>
)}
</Tabs>
</div>
);
};
@@ -0,0 +1,68 @@
import { createContext, useContext } from 'react';
export type ConfigureDocumentContext = {
// General
isTemplate: boolean;
isPersisted: boolean;
// Features
features: {
allowConfigureSignatureTypes?: boolean;
allowConfigureLanguage?: boolean;
allowConfigureDateFormat?: boolean;
allowConfigureTimezone?: boolean;
allowConfigureRedirectUrl?: boolean;
allowConfigureCommunication?: boolean;
};
};
const ConfigureDocumentContext = createContext<ConfigureDocumentContext | null>(null);
export type ConfigureDocumentProviderProps = {
isTemplate?: boolean;
isPersisted?: boolean;
features: {
allowConfigureSignatureTypes?: boolean;
allowConfigureLanguage?: boolean;
allowConfigureDateFormat?: boolean;
allowConfigureTimezone?: boolean;
allowConfigureRedirectUrl?: boolean;
allowConfigureCommunication?: boolean;
};
children: React.ReactNode;
};
export const ConfigureDocumentProvider = ({
isTemplate,
isPersisted,
features,
children,
}: ConfigureDocumentProviderProps) => {
return (
<ConfigureDocumentContext.Provider
value={{
isTemplate: isTemplate ?? false,
isPersisted: isPersisted ?? false,
features: {
allowConfigureSignatureTypes: features.allowConfigureSignatureTypes ?? true,
allowConfigureLanguage: features.allowConfigureLanguage ?? true,
allowConfigureDateFormat: features.allowConfigureDateFormat ?? true,
allowConfigureTimezone: features.allowConfigureTimezone ?? true,
allowConfigureRedirectUrl: features.allowConfigureRedirectUrl ?? true,
allowConfigureCommunication: features.allowConfigureCommunication ?? true,
},
}}
>
{children}
</ConfigureDocumentContext.Provider>
);
};
export const useConfigureDocument = () => {
const context = useContext(ConfigureDocumentContext);
if (!context) {
throw new Error('useConfigureDocument must be used within a ConfigureDocumentProvider');
}
return context;
};
@@ -0,0 +1,393 @@
import { useCallback, useRef } from 'react';
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVertical, HelpCircle, Plus, Trash } from 'lucide-react';
import { nanoid } from 'nanoid';
import type { Control } from 'react-hook-form';
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
// Define a type for signer items
type SignerItem = TConfigureEmbedFormSchema['signers'][number];
export interface ConfigureDocumentRecipientsProps {
control: Control<TConfigureEmbedFormSchema>;
isSubmitting: boolean;
}
export const ConfigureDocumentRecipients = ({
control,
isSubmitting,
}: ConfigureDocumentRecipientsProps) => {
const { _ } = useLingui();
const { isTemplate } = useConfigureDocument();
const $sensorApi = useRef<SensorAPI | null>(null);
const {
fields: signers,
append: appendSigner,
remove: removeSigner,
replace,
move,
} = useFieldArray({
control,
name: 'signers',
});
const { getValues, watch } = useFormContext<TConfigureEmbedFormSchema>();
const signingOrder = watch('meta.signingOrder');
const { errors } = useFormState({
control,
});
const onAddSigner = useCallback(() => {
const signerNumber = signers.length + 1;
appendSigner({
formId: nanoid(8),
name: isTemplate ? `Recipient ${signerNumber}` : '',
email: isTemplate ? `recipient.${signerNumber}@document.com` : '',
role: RecipientRole.SIGNER,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1,
});
}, [appendSigner, signers]);
const isSigningOrderEnabled = signingOrder === DocumentSigningOrder.SEQUENTIAL;
const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => {
const trimmedOrderString = newOrderString.trim();
if (!trimmedOrderString) {
return;
}
const newOrder = Number(trimmedOrderString);
if (!Number.isInteger(newOrder) || newOrder < 1) {
return;
}
// Get current form values to preserve unsaved input data
const currentSigners = getValues('signers') || [...signers];
const signer = currentSigners[index];
// Remove signer from current position and insert at new position
const remainingSigners = currentSigners.filter((_: unknown, idx: number) => idx !== index);
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
remainingSigners.splice(newPosition, 0, signer);
// Update signing order for each item
const updatedSigners = remainingSigners.map((s: SignerItem, idx: number) => ({
...s,
signingOrder: idx + 1,
}));
// Update the form
replace(updatedSigners);
},
[signers, replace, getValues],
);
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) return;
// Use the move function from useFieldArray which preserves input values
move(result.source.index, result.destination.index);
// Update signing orders after move
const currentSigners = getValues('signers');
const updatedSigners = currentSigners.map((signer: SignerItem, index: number) => ({
...signer,
signingOrder: index + 1,
}));
// Update the form with new ordering
replace(updatedSigners);
},
[move, replace, getValues],
);
return (
<div>
<h3 className="text-foreground mb-1 text-lg font-medium">
<Trans>Recipients</Trans>
</h3>
<p className="text-muted-foreground mb-6 text-sm">
<Trans>Add signers and configure signing preferences</Trans>
</p>
<FormField
control={control}
name="meta.signingOrder"
render={({ field }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) => {
field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
);
}}
disabled={isSubmitting}
/>
</FormControl>
<FormLabel
htmlFor="signingOrder"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Enable signing order</Trans>
</FormLabel>
</FormItem>
)}
/>
<FormField
control={control}
name="meta.allowDictateNextSigner"
render={({ field: { value, ...field } }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={field.onChange}
disabled={isSubmitting || !isSigningOrderEnabled}
/>
</FormControl>
<div className="flex items-center">
<FormLabel
htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Allow signers to dictate next signer</Trans>
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<HelpCircle className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>
When enabled, signers can choose who should sign next in the sequence instead
of following the predefined order.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
<DragDropContext
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
{signers.map((signer, index) => (
<Draggable
key={signer.id}
draggableId={signer.id}
index={index}
isDragDisabled={!isSigningOrderEnabled || isSubmitting}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'bg-widget-foreground pointer-events-none rounded-md pt-2':
snapshot.isDragging,
})}
>
<motion.div
className={cn('flex items-end gap-2 pb-2', {
'border-destructive/50': errors?.signers?.[index],
})}
>
{isSigningOrderEnabled && (
<FormField
control={control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('flex w-16 flex-none items-center gap-x-1', {
'mb-6':
errors?.signers?.[index] &&
!errors?.signers?.[index]?.signingOrder,
})}
>
<GripVertical className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={signers.length}
min={1}
className="w-full text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
{...field}
disabled={isSubmitting || snapshot.isDragging}
onChange={(e) => {
field.onChange(e);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('flex-1', {
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.name,
})}
>
<FormLabel className="sr-only">
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
placeholder={_(msg`Name`)}
className="w-full"
{...field}
disabled={isSubmitting || snapshot.isDragging}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('flex-1', {
'mb-6':
errors?.signers?.[index] && !errors?.signers?.[index]?.email,
})}
>
<FormLabel className="sr-only">
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
type="email"
placeholder={_(msg`Email`)}
className="w-full"
{...field}
disabled={isSubmitting || snapshot.isDragging}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('flex-none', {
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.role,
})}
>
<FormLabel className="sr-only">
<Trans>Role</Trans>
</FormLabel>
<FormControl>
<RecipientRoleSelect
{...field}
isAssistantEnabled={isSigningOrderEnabled}
onValueChange={field.onChange}
disabled={isSubmitting || snapshot.isDragging}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
disabled={isSubmitting || signers.length === 1 || snapshot.isDragging}
onClick={() => removeSigner(index)}
>
<Trash className="h-4 w-4" />
</Button>
</motion.div>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<div className="mt-4 flex justify-end">
<Button
type="button"
variant="outline"
className="w-auto"
disabled={isSubmitting}
onClick={onAddSigner}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
<Trans>Add Signer</Trans>
</Button>
</div>
</div>
);
};
@@ -0,0 +1,238 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Cloud, FileText, Loader, X } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { useFormContext } from 'react-hook-form';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
export interface ConfigureDocumentUploadProps {
isSubmitting?: boolean;
}
export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocumentUploadProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { isPersisted } = useConfigureDocument();
const form = useFormContext<TConfigureEmbedFormSchema>();
const [isLoading, setIsLoading] = useState(false);
// Watch the documentData field from the form
const documentData = form.watch('documentData');
const onFileDrop = async (acceptedFiles: File[]) => {
try {
const file = acceptedFiles[0];
if (!file) {
return;
}
setIsLoading(true);
// Convert file to UInt8Array
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// Store file metadata and UInt8Array in form data
form.setValue('documentData', {
name: file.name,
type: file.type,
size: file.size,
data: uint8Array, // Store as UInt8Array
});
// Auto-populate title if it's empty
const currentTitle = form.getValues('title');
if (!currentTitle) {
// Get filename without extension
const fileNameWithoutExtension = file.name.replace(/\.[^/.]+$/, '');
form.setValue('title', fileNameWithoutExtension);
}
} catch (error) {
console.error('Error uploading file', error);
toast({
title: _(msg`Error uploading file`),
description: _(msg`There was an error uploading your file. Please try again.`),
variant: 'destructive',
duration: 5000,
});
} finally {
setIsLoading(false);
}
};
const onDropRejected = () => {
toast({
title: _(msg`Your document failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
duration: 5000,
variant: 'destructive',
});
};
const onRemoveFile = () => {
if (isPersisted) {
toast({
title: _(msg`Cannot remove document`),
description: _(msg`The document is already saved and cannot be changed.`),
duration: 5000,
variant: 'destructive',
});
return;
}
form.unregister('documentData');
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
maxSize: APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024,
multiple: false,
disabled: isSubmitting || isLoading || isPersisted,
onDrop: (files) => {
void onFileDrop(files);
},
onDropRejected,
});
return (
<div>
<FormField
control={form.control}
name="documentData"
render={() => (
<FormItem>
<FormLabel required>
<Trans>Upload Document</Trans>
</FormLabel>
<div className="relative">
{!documentData ? (
<div className="relative">
<FormControl>
<div
{...getRootProps()}
className={cn(
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition',
{
'border-primary/50 bg-primary/5': isDragActive,
'hover:bg-muted/30':
!isDragActive && !isSubmitting && !isLoading && !isPersisted,
'cursor-not-allowed opacity-60': isSubmitting || isLoading || isPersisted,
},
)}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center justify-center gap-y-2 px-4 py-4 text-center">
<Cloud
className={cn('h-10 w-10', {
'text-primary': isDragActive,
'text-muted-foreground': !isDragActive,
})}
/>
<div
className={cn('flex flex-col space-y-1', {
'text-primary': isDragActive,
'text-muted-foreground': !isDragActive,
})}
>
<p className="text-sm font-medium">
{isDragActive ? (
<Trans>Drop your document here</Trans>
) : isPersisted ? (
<Trans>Document is already uploaded</Trans>
) : (
<Trans>Drag and drop or click to upload</Trans>
)}
</p>
<p className="text-xs">
{isPersisted ? (
<Trans>This document cannot be changed</Trans>
) : (
<Trans>
.PDF documents accepted (max {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB)
</Trans>
)}
</p>
</div>
</div>
</div>
</FormControl>
{isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
</div>
)}
</div>
) : (
<div className="mt-2 rounded-lg border p-4">
<div className="flex items-center gap-x-4">
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md">
<FileText className="h-6 w-6" />
</div>
<div className="flex-1">
<div className="text-sm font-medium">{documentData.name}</div>
<div className="text-muted-foreground text-xs">
{formatFileSize(documentData.size)}
</div>
</div>
{!isPersisted && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onRemoveFile}
disabled={isSubmitting}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
);
};
@@ -0,0 +1,131 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { nanoid } from 'nanoid';
import { useForm } from 'react-hook-form';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { ConfigureDocumentAdvancedSettings } from './configure-document-advanced-settings';
import { useConfigureDocument } from './configure-document-context';
import { ConfigureDocumentRecipients } from './configure-document-recipients';
import { ConfigureDocumentUpload } from './configure-document-upload';
import {
type TConfigureEmbedFormSchema,
ZConfigureEmbedFormSchema,
} from './configure-document-view.types';
export interface ConfigureDocumentViewProps {
onSubmit: (data: TConfigureEmbedFormSchema) => void | Promise<void>;
defaultValues?: Partial<TConfigureEmbedFormSchema>;
isSubmitting?: boolean;
}
export const ConfigureDocumentView = ({ onSubmit, defaultValues }: ConfigureDocumentViewProps) => {
const { isTemplate } = useConfigureDocument();
const form = useForm<TConfigureEmbedFormSchema>({
resolver: zodResolver(ZConfigureEmbedFormSchema),
defaultValues: {
title: defaultValues?.title || '',
signers: defaultValues?.signers || [
{
formId: nanoid(8),
name: isTemplate ? `Recipient ${1}` : '',
email: isTemplate ? `recipient.${1}@document.com` : '',
role: RecipientRole.SIGNER,
signingOrder: 1,
},
],
meta: {
subject: defaultValues?.meta?.subject || '',
message: defaultValues?.meta?.message || '',
distributionMethod:
defaultValues?.meta?.distributionMethod || DocumentDistributionMethod.EMAIL,
emailSettings: defaultValues?.meta?.emailSettings || ZDocumentEmailSettingsSchema.parse({}),
dateFormat: defaultValues?.meta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT,
timezone: defaultValues?.meta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE,
redirectUrl: defaultValues?.meta?.redirectUrl || '',
language: defaultValues?.meta?.language || 'en',
signatureTypes: defaultValues?.meta?.signatureTypes || [],
signingOrder: defaultValues?.meta?.signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: defaultValues?.meta?.allowDictateNextSigner || false,
externalId: defaultValues?.meta?.externalId || '',
},
documentData: defaultValues?.documentData,
},
});
const { control, handleSubmit } = form;
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = handleSubmit(onSubmit);
return (
<div className="flex w-full flex-col space-y-8">
<div>
<h2 className="text-foreground mb-1 text-xl font-semibold">
{isTemplate ? <Trans>Configure Template</Trans> : <Trans>Configure Document</Trans>}
</h2>
<p className="text-muted-foreground text-sm">
{isTemplate ? (
<Trans>Set up your template properties and recipient information</Trans>
) : (
<Trans>Set up your document properties and recipient information</Trans>
)}
</p>
</div>
<Form {...form}>
<div className="flex flex-col space-y-8">
<div>
<FormField
control={control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Title</Trans>
</FormLabel>
<FormControl>
<Input {...field} disabled={isSubmitting} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<ConfigureDocumentUpload isSubmitting={isSubmitting} />
<ConfigureDocumentRecipients control={control} isSubmitting={isSubmitting} />
<ConfigureDocumentAdvancedSettings control={control} isSubmitting={isSubmitting} />
<div className="flex justify-end">
<Button
type="button"
onClick={onFormSubmit}
disabled={isSubmitting}
className="w-full sm:w-auto"
>
<Trans>Continue</Trans>
</Button>
</div>
</div>
</Form>
</div>
);
};
@@ -0,0 +1,48 @@
import { z } from 'zod';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaLanguageSchema,
} from '@documenso/trpc/server/document-router/schema';
// Define the schema for configuration
export type TConfigureEmbedFormSchema = z.infer<typeof ZConfigureEmbedFormSchema>;
export const ZConfigureEmbedFormSchema = z.object({
title: z.string().min(1, { message: 'Title is required' }),
signers: z
.array(
z.object({
formId: z.string(),
name: z.string().min(1, { message: 'Name is required' }),
email: z.string().email('Invalid email address'),
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(),
}),
)
.min(1, { message: 'At least one signer is required' }),
meta: z.object({
subject: z.string().optional(),
message: z.string().optional(),
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
emailSettings: ZDocumentEmailSettingsSchema,
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
timezone: z.string().min(1, 'Timezone is required'),
redirectUrl: z.string().optional(),
language: ZDocumentMetaLanguageSchema.optional(),
signatureTypes: z.array(z.string()).default([]),
signingOrder: z.enum(['SEQUENTIAL', 'PARALLEL']),
allowDictateNextSigner: z.boolean().default(false),
externalId: z.string().optional(),
}),
documentData: z
.object({
name: z.string(),
type: z.string(),
size: z.number(),
data: z.instanceof(Uint8Array), // UInt8Array can't be directly validated by zod
})
.optional(),
});
@@ -0,0 +1,661 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentData } from '@prisma/client';
import { FieldType, ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { ChevronsUpDown } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { z } from 'zod';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { base64 } from '@documenso/lib/universal/base64';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
import { Form } from '@documenso/ui/primitives/form/form';
import PDFViewer from '@documenso/ui/primitives/pdf-viewer';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer';
const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36;
const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5;
const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export const ZConfigureFieldsFormSchema = z.object({
fields: z.array(
z.object({
formId: z.string().min(1),
id: z.string().min(1),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
recipientId: z.number().min(0),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
fieldMeta: ZFieldMetaSchema.optional(),
}),
),
});
export type TConfigureFieldsFormSchema = z.infer<typeof ZConfigureFieldsFormSchema>;
export type ConfigureFieldsViewProps = {
configData: TConfigureEmbedFormSchema;
defaultValues?: Partial<TConfigureFieldsFormSchema>;
onBack: (data: TConfigureFieldsFormSchema) => void;
onSubmit: (data: TConfigureFieldsFormSchema) => void;
};
export const ConfigureFieldsView = ({
configData,
defaultValues,
onBack,
onSubmit,
}: ConfigureFieldsViewProps) => {
const { toast } = useToast();
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
const { _ } = useLingui();
// Track if we're on a mobile device
const [isMobile, setIsMobile] = useState(false);
// State for managing the mobile drawer
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
// Check for mobile viewport on component mount and resize
useEffect(() => {
const checkIfMobile = () => {
setIsMobile(window.innerWidth < 768);
};
// Initial check
checkIfMobile();
// Add resize listener
window.addEventListener('resize', checkIfMobile);
// Cleanup
return () => {
window.removeEventListener('resize', checkIfMobile);
};
}, []);
const documentData = useMemo(() => {
if (!configData.documentData) {
return null;
}
const data = base64.encode(configData.documentData?.data);
return {
id: 'preview',
type: 'BYTES_64',
data,
initialData: data,
} satisfies DocumentData;
}, [configData.documentData]);
const recipients = useMemo(() => {
return configData.signers.map<Recipient>((signer, index) => ({
id: index,
name: signer.name || '',
email: signer.email || '',
role: signer.role,
signingOrder: signer.signingOrder || null,
documentId: null,
templateId: null,
token: '',
documentDeletedAt: null,
expired: null,
signedAt: null,
authOptions: null,
rejectionReason: null,
sendStatus: SendStatus.NOT_SENT,
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
}));
}, [configData.signers]);
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>(
() => recipients[0] || null,
);
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
const [coords, setCoords] = useState({
x: 0,
y: 0,
});
const [activeFieldId, setActiveFieldId] = useState<string | null>(null);
const [lastActiveField, setLastActiveField] = useState<
TConfigureFieldsFormSchema['fields'][0] | null
>(null);
const [fieldClipboard, setFieldClipboard] = useState<
TConfigureFieldsFormSchema['fields'][0] | null
>(null);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [currentField, setCurrentField] = useState<TConfigureFieldsFormSchema['fields'][0] | null>(
null,
);
const fieldBounds = useRef({
height: DEFAULT_HEIGHT_PX,
width: DEFAULT_WIDTH_PX,
});
const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id);
const selectedRecipientStyles = useRecipientColors(
selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex,
);
const form = useForm<TConfigureFieldsFormSchema>({
defaultValues: {
fields: defaultValues?.fields ?? [],
},
});
const { control, handleSubmit } = form;
const onFormSubmit = handleSubmit(onSubmit);
const {
append,
remove,
update,
fields: localFields,
} = useFieldArray({
control: control,
name: 'fields',
});
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
const { duplicate = false } = options ?? {};
if (lastActiveField) {
event?.preventDefault();
if (!duplicate) {
setFieldClipboard(lastActiveField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
return;
}
const newField: TConfigureFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
formId: nanoid(12),
id: nanoid(12),
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
append(newField);
}
},
[append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast],
);
const onFieldPaste = useCallback(
(event: KeyboardEvent) => {
if (fieldClipboard) {
event.preventDefault();
const copiedField = structuredClone(fieldClipboard);
append({
...copiedField,
formId: nanoid(12),
id: nanoid(12),
signerEmail: selectedRecipient?.email ?? copiedField.signerEmail,
recipientId: selectedRecipient?.id ?? copiedField.recipientId,
pageX: copiedField.pageX + 3,
pageY: copiedField.pageY + 3,
});
}
},
[append, fieldClipboard, selectedRecipient?.email, selectedRecipient?.id],
);
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
useHotkeys(['ctrl+v', 'meta+v'], (evt) => onFieldPaste(evt));
useHotkeys(['ctrl+d', 'meta+d'], (evt) => onFieldCopy(evt, { duplicate: true }));
const onMouseMove = useCallback(
(event: MouseEvent) => {
if (!selectedField) return;
setIsFieldWithinBounds(
isWithinPageBounds(
event,
PDF_VIEWER_PAGE_SELECTOR,
fieldBounds.current.width,
fieldBounds.current.height,
),
);
setCoords({
x: event.clientX - fieldBounds.current.width / 2,
y: event.clientY - fieldBounds.current.height / 2,
});
},
[isWithinPageBounds, selectedField],
);
const onMouseClick = useCallback(
(event: MouseEvent) => {
if (!selectedField || !selectedRecipient) {
return;
}
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
if (
!$page ||
!isWithinPageBounds(
event,
PDF_VIEWER_PAGE_SELECTOR,
fieldBounds.current.width,
fieldBounds.current.height,
)
) {
return;
}
const { top, left, height, width } = getBoundingClientRect($page);
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height
let pageX = ((event.pageX - left) / width) * 100;
let pageY = ((event.pageY - top) / height) * 100;
// Get the bounds as a percentage of the page width and height
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
// And center it based on the bounds
pageX -= fieldPageWidth / 2;
pageY -= fieldPageHeight / 2;
const field = {
id: nanoid(12),
formId: nanoid(12),
type: selectedField,
pageNumber,
pageX,
pageY,
pageWidth: fieldPageWidth,
pageHeight: fieldPageHeight,
recipientId: selectedRecipient.id,
signerEmail: selectedRecipient.email,
fieldMeta: undefined,
};
append(field);
// Automatically open advanced settings for field types that need configuration
if (ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING.includes(selectedField)) {
setCurrentField(field);
setShowAdvancedSettings(true);
}
setSelectedField(null);
},
[append, getPage, isWithinPageBounds, selectedField, selectedRecipient],
);
const onFieldResize = useCallback(
(node: HTMLElement, index: number) => {
const field = localFields[index];
const $page = window.document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page) {
return;
}
const {
x: pageX,
y: pageY,
width: pageWidth,
height: pageHeight,
} = getFieldPosition($page, node);
update(index, {
...field,
pageX,
pageY,
pageWidth,
pageHeight,
});
},
[getFieldPosition, localFields, update],
);
const onFieldMove = useCallback(
(node: HTMLElement, index: number) => {
const field = localFields[index];
const $page = window.document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page) {
return;
}
const { x: pageX, y: pageY } = getFieldPosition($page, node);
update(index, {
...field,
pageX,
pageY,
});
},
[getFieldPosition, localFields, update],
);
const handleUpdateFieldMeta = useCallback(
(formId: string, fieldMeta: TFieldMetaSchema) => {
const fieldIndex = localFields.findIndex((field) => field.formId === formId);
if (fieldIndex !== -1) {
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldMeta);
update(fieldIndex, {
...localFields[fieldIndex],
fieldMeta: parsedFieldMeta,
});
}
},
[localFields, update],
);
useEffect(() => {
if (selectedField) {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseClick);
}
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseClick);
};
}, [onMouseClick, onMouseMove, selectedField]);
useEffect(() => {
const observer = new MutationObserver((_mutations) => {
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
return;
}
fieldBounds.current = {
height: Math.max(DEFAULT_HEIGHT_PX),
width: Math.max(DEFAULT_WIDTH_PX),
};
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, []);
// Close drawer when a field is selected on mobile
useEffect(() => {
if (isMobile && selectedField) {
setIsDrawerOpen(false);
}
}, [isMobile, selectedField]);
return (
<>
<div className="grid w-full grid-cols-12 gap-4">
{/* Desktop sidebar */}
{!isMobile && (
<div className="order-2 col-span-12 md:order-1 md:col-span-4">
<div className="bg-widget border-border sticky top-4 max-h-[calc(100vh-2rem)] rounded-lg border p-4 pb-6">
<h2 className="mb-1 text-lg font-medium">
<Trans>Configure Fields</Trans>
</h2>
<p className="text-muted-foreground mb-6 text-sm">
<Trans>Configure the fields you want to place on the document.</Trans>
</p>
<RecipientSelector
selectedRecipient={selectedRecipient}
onSelectedRecipientChange={setSelectedRecipient}
recipients={recipients}
className="w-full"
/>
<hr className="my-6" />
<div className="space-y-2">
<FieldSelector
selectedField={selectedField}
onSelectedFieldChange={setSelectedField}
className="w-full"
disabled={!selectedRecipient}
/>
</div>
<div className="mt-6 flex gap-2">
<Button
type="button"
variant="ghost"
className="flex-1"
loading={form.formState.isSubmitting}
onClick={() => onBack(form.getValues())}
>
<Trans>Back</Trans>
</Button>
<Button
className="flex-1"
type="button"
loading={form.formState.isSubmitting}
disabled={!form.formState.isValid}
onClick={async () => onFormSubmit()}
>
<Trans>Save</Trans>
</Button>
</div>
</div>
</div>
)}
<div className={cn('order-1 col-span-12 md:order-2', !isMobile && 'md:col-span-8')}>
<div className="relative">
{selectedField && (
<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 bg-white transition duration-200 [container-type:size]',
selectedRecipientStyles.base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
},
selectedField === 'SIGNATURE' && 'font-signature',
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
{_(FRIENDLY_FIELD_TYPE[selectedField])}
</span>
</div>
)}
<Form {...form}>
{documentData && (
<div>
<PDFViewer documentData={documentData} />
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field, index) => {
const recipientIndex = recipients.findIndex(
(r) => r.id === field.recipientId,
);
return (
<FieldItem
key={field.formId}
field={field}
minHeight={MIN_HEIGHT_PX}
minWidth={MIN_WIDTH_PX}
defaultHeight={DEFAULT_HEIGHT_PX}
defaultWidth={DEFAULT_WIDTH_PX}
onResize={(node) => onFieldResize(node, index)}
onMove={(node) => onFieldMove(node, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)}
onAdvancedSettings={() => {
setCurrentField(field);
setShowAdvancedSettings(true);
}}
recipientIndex={recipientIndex}
active={activeFieldId === field.formId}
onFieldActivate={() => setActiveFieldId(field.formId)}
onFieldDeactivate={() => setActiveFieldId(null)}
disabled={selectedRecipient?.id !== field.recipientId}
/>
);
})}
</ElementVisible>
</div>
)}
</Form>
</div>
</div>
</div>
{/* Mobile Floating Action Bar and Drawer */}
{isMobile && (
<Sheet open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<SheetTrigger asChild>
<div className="bg-widget border-border fixed bottom-6 left-6 right-6 z-50 flex items-center justify-between gap-2 rounded-lg border p-4">
<span className="text-lg font-medium">
<Trans>Configure Fields</Trans>
</span>
<button
type="button"
className="border-border text-muted-foreground inline-flex h-10 w-10 items-center justify-center rounded-lg border"
>
<ChevronsUpDown className="h-6 w-6" />
</button>
</div>
</SheetTrigger>
<SheetContent
position="bottom"
size="xl"
className="bg-widget h-fit max-h-[80vh] overflow-y-auto rounded-t-xl p-4"
>
<h2 className="mb-1 text-lg font-medium">
<Trans>Configure Fields</Trans>
</h2>
<p className="text-muted-foreground mb-6 text-sm">
<Trans>Configure the fields you want to place on the document.</Trans>
</p>
<RecipientSelector
selectedRecipient={selectedRecipient}
onSelectedRecipientChange={setSelectedRecipient}
recipients={recipients}
className="w-full"
/>
<hr className="my-6" />
<div className="space-y-2">
<FieldSelector
selectedField={selectedField}
onSelectedFieldChange={(field) => {
setSelectedField(field);
if (field) {
setIsDrawerOpen(false);
}
}}
className="w-full"
disabled={!selectedRecipient}
/>
</div>
<div className="mt-6 flex gap-2">
<Button
type="button"
variant="ghost"
className="flex-1"
loading={form.formState.isSubmitting}
onClick={() => onBack(form.getValues())}
>
<Trans>Back</Trans>
</Button>
<Button
className="flex-1"
type="button"
loading={form.formState.isSubmitting}
disabled={!form.formState.isValid}
onClick={async () => onFormSubmit()}
>
<Trans>Save</Trans>
</Button>
</div>
</SheetContent>
</Sheet>
)}
<FieldAdvancedSettingsDrawer
isOpen={showAdvancedSettings}
onOpenChange={setShowAdvancedSettings}
currentField={currentField}
fields={localFields}
onFieldUpdate={handleUpdateFieldMeta}
/>
</>
);
};
@@ -0,0 +1,83 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { FieldType } from '@prisma/client';
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { FieldAdvancedSettings } from '@documenso/ui/primitives/document-flow/field-item-advanced-settings';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
export type FieldAdvancedSettingsDrawerProps = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
currentField: {
id: string;
formId: string;
type: FieldType;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
recipientId: number;
signerEmail: string;
fieldMeta?: FieldMeta;
} | null;
fields: Array<{
id: string;
formId: string;
type: FieldType;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
recipientId: number;
signerEmail: string;
fieldMeta?: FieldMeta;
}>;
onFieldUpdate: (formId: string, fieldMeta: FieldMeta) => void;
};
export const FieldAdvancedSettingsDrawer = ({
isOpen,
onOpenChange,
currentField,
fields,
onFieldUpdate,
}: FieldAdvancedSettingsDrawerProps) => {
const { _ } = useLingui();
if (!currentField) {
return null;
}
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent position="right" size="lg" className="w-9/12 max-w-sm overflow-y-auto">
<SheetTitle className="sr-only">
{parseMessageDescriptor(
_,
msg`Configure ${parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[currentField.type])} Field`,
)}
</SheetTitle>
<FieldAdvancedSettings
title={msg`Advanced settings`}
description={msg`Configure the ${parseMessageDescriptor(
_,
FRIENDLY_FIELD_TYPE[currentField.type],
)} field`}
field={currentField}
fields={fields}
onAdvancedSettings={() => onOpenChange(false)}
onSave={(fieldMeta) => {
onFieldUpdate(currentField.formId, fieldMeta);
onOpenChange(false);
}}
/>
</SheetContent>
</Sheet>
);
};
@@ -1,7 +1,11 @@
import { Loader } from 'lucide-react';
export const EmbedClientLoading = () => {
return (
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
Loading...
<Loader className="mr-2 h-4 w-4 animate-spin" />
<span>Loading...</span>
</div>
);
};
@@ -20,6 +20,7 @@ import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@@ -37,7 +38,6 @@ import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-sc
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
import { DocumentReadOnlyFields } from '../general/document/document-read-only-fields';
import { EmbedClientLoading } from './embed-client-loading';
import { EmbedDocumentCompleted } from './embed-document-completed';
import { EmbedDocumentFields } from './embed-document-fields';
@@ -9,6 +9,10 @@ import { z } from 'zod';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TTemplate } from '@documenso/lib/types/template';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '@documenso/ui/components/document/document-read-only-fields';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
@@ -16,7 +20,6 @@ import {
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { ShowFieldItem } from '@documenso/ui/primitives/document-flow/show-field-item';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import {
Form,
@@ -97,14 +100,16 @@ export const DirectTemplateConfigureForm = ({
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
directTemplateRecipient.fields.map((field, index) => (
<ShowFieldItem
key={index}
field={field}
recipients={recipientsWithBlankDirectRecipientEmail}
/>
))}
{isDocumentPdfLoaded && (
<DocumentReadOnlyFields
fields={mapFieldsWithRecipients(
directTemplateRecipient.fields,
recipientsWithBlankDirectRecipientEmail,
)}
recipientIds={recipients.map((recipient) => recipient.id)}
showRecipientColors={true}
/>
)}
<Form {...form}>
<fieldset
@@ -8,6 +8,7 @@ import { useNavigate, useSearchParams } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TTemplate } from '@documenso/lib/types/template';
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
@@ -103,11 +104,17 @@ export const DirectTemplatePageView = ({
directRecipientEmail: recipient.email,
templateUpdatedAt: template.updatedAt,
signedFieldValues: fields.map((field) => {
if (!field.signedValue) {
if (isRequiredField(field) && !field.signedValue) {
throw new Error('Invalid configuration');
}
return field.signedValue;
return {
token: field.signedValue?.token ?? '',
fieldId: field.signedValue?.fieldId ?? 0,
value: field.signedValue?.value,
isBase64: field.signedValue?.isBase64,
authOptions: field.signedValue?.authOptions,
};
}),
});
@@ -17,6 +17,7 @@ import {
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { TTemplate } from '@documenso/lib/types/template';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
@@ -78,6 +79,10 @@ export const DirectTemplateSigningForm = ({
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const fieldsRequiringValidation = useMemo(() => {
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
}, [localFields]);
const { currentStep, totalSteps, previousStep } = useStep();
const onSignField = (value: TSignFieldWithTokenMutationSchema) => {
@@ -134,18 +139,18 @@ export const DirectTemplateSigningForm = ({
};
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
return sortFieldsByPosition(fieldsRequiringValidation);
}, [localFields]);
const fieldsValidated = () => {
setValidateUninsertedFields(true);
validateFieldsInserted(localFields);
validateFieldsInserted(fieldsRequiringValidation);
};
const handleSubmit = async () => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(localFields);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
if (!isFieldsValid) {
return;
@@ -97,6 +97,12 @@ export const DocumentSigningCheckboxField = ({
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
// Do nothing, this should only happen when the user clicks the field, but
// misses the checkbox which triggers this callback.
if (checkedValues.length === 0) {
return;
}
if (!isLengthConditionMet) {
return;
}
@@ -270,21 +276,26 @@ export const DocumentSigningCheckboxField = ({
{validationSign?.label} {checkboxValidationLength}
</FieldToolTip>
)}
<div className="z-50 flex flex-col gap-y-2">
<div className="z-50 my-0.5 flex flex-col gap-y-1">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`;
return (
<div key={index} className="flex items-center gap-x-1.5">
<div key={index} className="flex items-center">
<Checkbox
className="h-4 w-4"
id={`checkbox-${index}`}
className="h-3 w-3"
id={`checkbox-${field.id}-${item.id}`}
checked={checkedValues.includes(itemValue)}
onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
/>
<Label htmlFor={`checkbox-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`checkbox-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
</div>
);
})}
@@ -293,22 +304,27 @@ export const DocumentSigningCheckboxField = ({
)}
{field.inserted && (
<div className="flex flex-col gap-y-1">
<div className="my-0.5 flex flex-col gap-y-1">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`;
return (
<div key={index} className="flex items-center gap-x-1.5">
<div key={index} className="flex items-center">
<Checkbox
className="h-3 w-3"
id={`checkbox-${index}`}
id={`checkbox-${field.id}-${item.id}`}
checked={parsedCheckedValues.includes(itemValue)}
disabled={isLoading}
onCheckedChange={() => void handleCheckboxOptionClick(item)}
/>
<Label htmlFor={`checkbox-${index}`} className="text-xs">
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`checkbox-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
</div>
);
})}
@@ -281,7 +281,7 @@ export const DocumentSigningCompleteDialog = ({
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
className="flex-1"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
@@ -142,7 +142,7 @@ export const DocumentSigningDateField = ({
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<Trans>Date</Trans>
</p>
)}
@@ -151,12 +151,10 @@ export const DocumentSigningDateField = ({
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
'text-foreground w-full whitespace-nowrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
'!text-center': parsedFieldMeta?.textAlign === 'center',
'!text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
@@ -177,15 +177,11 @@ export const DocumentSigningDropdownField = ({
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200">
<p className="group-hover:text-primary text-foreground flex flex-col items-center justify-center duration-200">
<Select value={localChoice} onValueChange={handleSelectItem}>
<SelectTrigger
className={cn(
'text-muted-foreground z-10 h-full w-full border-none ring-0 focus:ring-0',
{
'hover:text-red-300': parsedFieldMeta.required,
'hover:text-yellow-300': !parsedFieldMeta.required,
},
'text-foreground z-10 h-full w-full border-none ring-0 focus:border-none focus:ring-0',
)}
>
<SelectValue
@@ -205,7 +201,7 @@ export const DocumentSigningDropdownField = ({
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<p className="text-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
)}
@@ -1,7 +1,6 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@@ -14,10 +13,14 @@ import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
@@ -120,34 +123,18 @@ export const DocumentSigningEmailField = ({
return (
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<DocumentSigningFieldsUninserted>
<Trans>Email</Trans>
</p>
</DocumentSigningFieldsUninserted>
)}
{field.inserted && (
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText}
</DocumentSigningFieldsInserted>
)}
</DocumentSigningFieldContainer>
);
@@ -2,12 +2,14 @@ import React from 'react';
import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { TooltipArrow } from '@radix-ui/react-tooltip';
import { X } from 'lucide-react';
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
@@ -128,57 +130,51 @@ export const DocumentSigningFieldContainer = ({
};
return (
<div className={cn('[container-type:size]', { group: type === 'Checkbox' })}>
<FieldRootContainer field={field}>
<div className={cn('[container-type:size]')}>
<FieldRootContainer color={RECIPIENT_COLOR_STYLES.green} field={field}>
{!field.inserted && !loading && !readOnlyField && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full rounded-md border"
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
onClick={async () => handleInsertField()}
/>
)}
{readOnlyField && (
<button className="bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100">
<span className="bg-foreground/50 dark:bg-background/50 text-background dark:text-foreground rounded-xl p-2">
<span className="bg-foreground/50 text-background rounded-xl p-2">
<Trans>Read only field</Trans>
</span>
</button>
)}
{type === 'Date' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
<Trans>Remove</Trans>
</button>
</TooltipTrigger>
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
</Tooltip>
)}
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="dark:bg-background absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
onClick={() => void onClearCheckBoxValues(type)}
>
<span className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<X className="h-4 w-4" />
</span>
</button>
)}
{type !== 'Date' && type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="text-destructive bg-background/50 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
<Trans>Remove</Trans>
</button>
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
</TooltipTrigger>
<TooltipContent
className="border-0 bg-orange-300 fill-orange-300 font-bold text-orange-900"
sideOffset={2}
>
{tooltipText && <p>{tooltipText}</p>}
<Trans>Remove</Trans>
<TooltipArrow />
</TooltipContent>
</Tooltip>
)}
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
@@ -0,0 +1,51 @@
import { Loader } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
export const DocumentSigningFieldsLoader = () => {
return (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
);
};
export const DocumentSigningFieldsUninserted = ({ children }: { children: React.ReactNode }) => {
return (
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{children}
</p>
);
};
type DocumentSigningFieldsInsertedProps = {
children: React.ReactNode;
/**
* The text alignment of the field.
*
* Defaults to left.
*/
textAlign?: 'left' | 'center' | 'right';
};
export const DocumentSigningFieldsInserted = ({
children,
textAlign = 'left',
}: DocumentSigningFieldsInsertedProps) => {
return (
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-foreground w-full text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'!text-center': textAlign === 'center',
'!text-right': textAlign === 'right',
},
)}
>
{children}
</p>
</div>
);
};
@@ -1,12 +1,12 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZInitialsFieldMeta } from '@documenso/lib/types/field-meta';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
@@ -17,6 +17,11 @@ import type {
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
@@ -50,6 +55,9 @@ export const DocumentSigningInitialsField = ({
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
const safeFieldMeta = ZInitialsFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
const value = initials ?? '';
@@ -122,22 +130,18 @@ export const DocumentSigningInitialsField = ({
onRemove={onRemove}
type="Initials"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<DocumentSigningFieldsUninserted>
<Trans>Initials</Trans>
</p>
</DocumentSigningFieldsUninserted>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText}
</p>
</DocumentSigningFieldsInserted>
)}
</DocumentSigningFieldContainer>
);
@@ -3,7 +3,6 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@@ -16,7 +15,6 @@ import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
@@ -25,6 +23,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
@@ -166,34 +169,18 @@ export const DocumentSigningNameField = ({
onRemove={onRemove}
type="Name"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
<DocumentSigningFieldsUninserted>
<Trans>Name</Trans>
</p>
</DocumentSigningFieldsUninserted>
)}
{field.inserted && (
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText}
</DocumentSigningFieldsInserted>
)}
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
@@ -202,7 +189,7 @@ export const DocumentSigningNameField = ({
<Trans>
Sign as
<div>
{recipient.name} <div className="text-muted-foreground">({recipient.email})</div>
{recipient.name} <div className="text-foreground">({recipient.email})</div>
</div>
</Trans>
</DialogTitle>
@@ -224,7 +211,7 @@ export const DocumentSigningNameField = ({
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
className="flex-1"
variant="secondary"
onClick={() => {
setShowFullNameModal(false);
@@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Hash, Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
@@ -25,6 +24,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
type ValidationErrors = {
@@ -245,45 +249,16 @@ export const DocumentSigningNumberField = ({
onRemove={onRemove}
type="Number"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
<p
className={cn(
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
{
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
},
)}
>
<span className="flex items-center justify-center gap-x-1">
<Hash className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />{' '}
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{fieldDisplayName}</span>
</span>
</p>
<DocumentSigningFieldsUninserted>{fieldDisplayName}</DocumentSigningFieldsUninserted>
)}
{field.inserted && (
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText}
</DocumentSigningFieldsInserted>
)}
<Dialog open={showNumberModal} onOpenChange={setShowNumberModal}>
@@ -339,7 +314,7 @@ export const DocumentSigningNumberField = ({
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
className="flex-1"
variant="secondary"
onClick={() => {
setShowNumberModal(false);
@@ -19,6 +19,7 @@ import {
import type { CompletedField } from '@documenso/lib/types/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
@@ -36,7 +37,6 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
@@ -157,7 +157,11 @@ export const DocumentSigningPageView = ({
</div>
</div>
<DocumentReadOnlyFields documentMeta={documentMeta || undefined} fields={completedFields} />
<DocumentReadOnlyFields
documentMeta={documentMeta || undefined}
fields={completedFields}
showRecipientTooltip={true}
/>
{recipient.role !== RecipientRole.ASSISTANT && (
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@@ -21,6 +20,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { DocumentSigningFieldsLoader } from './document-signing-fields';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningRadioFieldProps = {
@@ -150,44 +150,52 @@ export const DocumentSigningRadioField = ({
return (
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Radio">
{isLoading && (
<div className="bg-background absolute inset-0 z-20 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10">
<RadioGroup
onValueChange={(value) => handleSelectItem(value)}
className="z-10 my-0.5 gap-y-1"
>
{values?.map((item, index) => (
<div key={index} className="flex items-center gap-x-1.5">
<div key={index} className="flex items-center">
<RadioGroupItem
className="h-4 w-4 shrink-0"
className="h-3 w-3 shrink-0"
value={item.value}
id={`option-${index}`}
id={`option-${field.id}-${item.id}`}
checked={item.checked}
/>
<Label htmlFor={`option-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`option-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
</div>
))}
</RadioGroup>
)}
{field.inserted && (
<RadioGroup className="gap-y-1">
<RadioGroup className="my-0.5 gap-y-1">
{values?.map((item, index) => (
<div key={index} className="flex items-center gap-x-1.5">
<div key={index} className="flex items-center">
<RadioGroupItem
className="h-3 w-3"
value={item.value}
id={`option-${index}`}
id={`option-${field.id}-${item.id}`}
checked={item.value === field.customText}
/>
<Label htmlFor={`option-${index}`} className="text-xs">
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`option-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
</div>
))}
</RadioGroup>
@@ -242,7 +242,7 @@ export const DocumentSigningSignatureField = ({
)}
{state === 'empty' && (
<p className="group-hover:text-primary font-signature text-muted-foreground text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200 group-hover:text-yellow-300">
<p className="group-hover:text-primary font-signature text-muted-foreground group-hover:text-recipient-green text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200">
<Trans>Signature</Trans>
</p>
)}
@@ -259,7 +259,7 @@ export const DocumentSigningSignatureField = ({
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
<p
ref={signatureRef}
className="font-signature text-muted-foreground dark:text-background w-full overflow-hidden break-all text-center leading-tight duration-200"
className="font-signature text-muted-foreground w-full overflow-hidden break-all text-center leading-tight duration-200"
style={{ fontSize: `${fontSize}rem` }}
>
{signature?.typedSignature}
@@ -291,7 +291,7 @@ export const DocumentSigningSignatureField = ({
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
className="flex-1"
variant="secondary"
onClick={() => {
setShowSignatureModal(false);
@@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { Loader, Type } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
@@ -25,6 +24,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningTextFieldProps = {
@@ -248,49 +252,20 @@ export const DocumentSigningTextField = ({
onRemove={onRemove}
type="Text"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
<p
className={cn(
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
{
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
},
)}
>
<span className="flex items-center justify-center gap-x-1">
<Type className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
{fieldDisplayName || <Trans>Text</Trans>}
</span>
</span>
</p>
<DocumentSigningFieldsUninserted>
{fieldDisplayName || <Trans>Text</Trans>}
</DocumentSigningFieldsUninserted>
)}
{field.inserted && (
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 20) + '...'}
</p>
</div>
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 20) + '...'}
</DocumentSigningFieldsInserted>
)}
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
@@ -304,11 +279,9 @@ export const DocumentSigningTextField = ({
id="custom-text"
placeholder={parsedFieldMeta?.placeholder ?? _(msg`Enter your text here`)}
className={cn('mt-2 w-full rounded-md', {
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
'border-2 border-red-300 text-left ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
userInputHasErrors,
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-center': parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
})}
value={localText}
@@ -354,8 +327,8 @@ export const DocumentSigningTextField = ({
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
className="flex-1"
onClick={() => {
setShowCustomTextModal(false);
setLocalCustomText('');
@@ -1,171 +0,0 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
import { FieldType, SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
import { P, match } from 'ts-pattern';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PopoverHover } from '@documenso/ui/primitives/popover';
export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[];
documentMeta?: DocumentMeta | TemplateMeta;
showFieldStatus?: boolean;
};
export const DocumentReadOnlyFields = ({
documentMeta,
fields,
showFieldStatus = true,
}: DocumentReadOnlyFieldsProps) => {
const { _ } = useLingui();
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
const handleHideField = (fieldId: string) => {
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
return (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
<FieldRootContainer
field={field}
key={field.id}
cardClassName="border-gray-300/50 !shadow-none backdrop-blur-[1px] bg-gray-50 ring-0 ring-offset-0"
>
<div className="absolute -right-3 -top-3">
<PopoverHover
trigger={
<Avatar className="dark:border-foreground h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
{extractInitials(field.recipient.name || field.recipient.email)}
</AvatarFallback>
</Avatar>
}
contentProps={{
className: 'relative flex w-fit flex-col p-4 text-sm',
}}
>
{showFieldStatus && (
<Badge
className="mx-auto mb-1 py-0.5"
variant={
field.recipient.signingStatus === SigningStatus.SIGNED
? 'default'
: 'secondary'
}
>
{field.recipient.signingStatus === SigningStatus.SIGNED ? (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
) : (
<>
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</>
)}
</Badge>
)}
<p className="text-center font-semibold">
<span>{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])} field</span>
</p>
<p className="text-muted-foreground mt-1 text-center text-xs">
{field.recipient.name
? `${field.recipient.name} (${field.recipient.email})`
: field.recipient.email}{' '}
</p>
<button
className="absolute right-0 top-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
onClick={() => handleHideField(field.secondaryId)}
title="Hide field"
>
<EyeOffIcon className="h-3 w-3" />
</button>
</PopoverHover>
</div>
<div className="text-muted-foreground dark:text-background/70 break-all text-sm">
{field.recipient.signingStatus === SigningStatus.SIGNED &&
match(field)
.with({ type: FieldType.SIGNATURE }, (field) =>
field.signature?.signatureImageAsBase64 ? (
<img
src={field.signature.signatureImageAsBase64}
alt="Signature"
className="h-full w-full object-contain dark:invert"
/>
) : (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl">
{field.signature?.typedSignature}
</p>
),
)
.with(
{
type: P.union(
FieldType.NAME,
FieldType.INITIALS,
FieldType.EMAIL,
FieldType.NUMBER,
FieldType.RADIO,
FieldType.CHECKBOX,
FieldType.DROPDOWN,
),
},
() => field.customText,
)
.with({ type: FieldType.TEXT }, () => field.customText.substring(0, 20) + '...')
.with({ type: FieldType.DATE }, () =>
convertToLocalSystemFormat(
field.customText,
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
),
)
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
.exhaustive()}
{field.recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<p
className={cn('text-muted-foreground text-lg duration-200', {
'font-signature sm:text-xl md:text-2xl':
field.type === FieldType.SIGNATURE ||
field.type === FieldType.FREE_SIGNATURE,
})}
>
{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])}
</p>
)}
</div>
</FieldRootContainer>
),
)}
</ElementVisible>
);
};
@@ -0,0 +1,112 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertCircle } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type LegacyFieldWarningPopoverProps = {
type?: 'document' | 'template';
documentId?: number;
templateId?: number;
};
export const LegacyFieldWarningPopover = ({
type = 'document',
documentId,
templateId,
}: LegacyFieldWarningPopoverProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const revalidator = useRevalidator();
const { mutateAsync: updateTemplate, isPending: isUpdatingTemplate } =
trpc.template.updateTemplate.useMutation();
const { mutateAsync: updateDocument, isPending: isUpdatingDocument } =
trpc.document.updateDocument.useMutation();
const onUpdateFieldsClick = async () => {
if (type === 'document') {
if (!documentId) {
return;
}
await updateDocument({
documentId,
data: {
useLegacyFieldInsertion: false,
},
});
}
if (type === 'template') {
if (!templateId) {
return;
}
await updateTemplate({
templateId,
data: {
useLegacyFieldInsertion: false,
},
});
}
void revalidator.revalidate();
toast({
title: _(msg`Fields updated`),
description: _(
msg`The fields have been updated to the new field insertion method successfully`,
),
});
};
return (
<PopoverHover
side="bottom"
trigger={
<Button variant="outline" className="h-9 w-9 p-0">
<span className="sr-only">
{type === 'document' ? (
<Trans>Document is using legacy field insertion</Trans>
) : (
<Trans>Template is using legacy field insertion</Trans>
)}
</span>
<AlertCircle className="h-5 w-5" />
</Button>
}
>
<p className="text-muted-foreground text-sm">
{type === 'document' ? (
<Trans>
This document is using legacy field insertion, we recommend using the new field
insertion method for more accurate results.
</Trans>
) : (
<Trans>
This template is using legacy field insertion, we recommend using the new field
insertion method for more accurate results.
</Trans>
)}
</p>
<div className="mt-2 flex w-full items-center justify-end">
<Button
type="button"
size="sm"
loading={isUpdatingDocument || isUpdatingTemplate}
onClick={onUpdateFieldsClick}
>
<Trans>Update Fields</Trans>
</Button>
</div>
</PopoverHover>
);
};
@@ -13,6 +13,7 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -24,7 +25,6 @@ import { DocumentPageViewDropdown } from '~/components/general/document/document
import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information';
import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity';
import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients';
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import {
DocumentStatus as DocumentStatusComponent,
@@ -200,8 +200,14 @@ export default function DocumentPage() {
</CardContent>
</Card>
{document.status === DocumentStatus.PENDING && (
<DocumentReadOnlyFields fields={fields} documentMeta={documentMeta || undefined} />
{document.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields
fields={fields}
documentMeta={documentMeta || undefined}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
/>
)}
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
@@ -14,6 +14,7 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
import { DocumentStatus } from '~/components/general/document/document-status';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
@@ -100,29 +101,43 @@ export default function DocumentEditPage() {
<Trans>Documents</Trans>
</Link>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
<div className="mt-4 flex w-full items-end justify-between">
<div className="flex-1">
<h1
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus
inheritColor
status={document.status}
className="text-muted-foreground"
/>
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
</span>
</StackAvatarsWithTooltip>
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
</div>
{document.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="document" documentId={document.id} />
</div>
)}
</div>
@@ -7,6 +7,7 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
@@ -14,7 +15,6 @@ import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplatePageViewDocumentsTable } from '~/components/general/template/template-page-view-documents-table';
import { TemplatePageViewInformation } from '~/components/general/template/template-page-view-information';
@@ -151,6 +151,9 @@ export default function TemplatePage() {
<DocumentReadOnlyFields
fields={readOnlyFields}
showFieldStatus={false}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
documentMeta={mockedDocumentMeta}
/>
@@ -8,6 +8,7 @@ import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
import { TemplateType } from '~/components/general/template/template-type';
@@ -91,8 +92,14 @@ export default function TemplateEditPage() {
</div>
</div>
<div className="mt-2 sm:mt-0 sm:self-end">
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
{template.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="template" templateId={template.id} />
</div>
)}
</div>
</div>
@@ -249,24 +249,24 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
{signature.secondaryId}
</span>
</p>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">{_(msg`IP Address`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}
</span>
</p>
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
<span className="font-medium">{_(msg`Device`)}:</span>{' '}
<span className="inline-block">
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
</span>
</p>
</>
) : (
<p className="text-muted-foreground">N/A</p>
)}
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">{_(msg`IP Address`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}
</span>
</p>
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
<span className="font-medium">{_(msg`Device`)}:</span>{' '}
<span className="inline-block">
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
</span>
</p>
</TableCell>
<TableCell truncate={false} className="w-[min-content] align-top">
@@ -6,7 +6,7 @@ import { isTokenExpired } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
import type { Route } from './+types/team.verify.transfer.token';
import type { Route } from './+types/team.verify.transfer.$token';
export async function loader({ params }: Route.LoaderArgs) {
const { token } = params;
@@ -1,6 +1,6 @@
import sharp from 'sharp';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
import type { Route } from './+types/branding.logo.team.$teamId';
@@ -24,7 +24,14 @@ export async function loader({ params }: Route.LoaderArgs) {
},
});
console.log('########################');
console.log('########################');
console.log('########################');
console.log('########################');
console.log('########################');
if (!settings || !settings.brandingEnabled) {
console.log('----------------------------');
return Response.json(
{
status: 'error',
@@ -35,6 +42,7 @@ export async function loader({ params }: Route.LoaderArgs) {
}
if (!settings.brandingLogo) {
console.log('!!!!!!!!!!!!!!!!!!!!!!!!');
return Response.json(
{
status: 'error',
@@ -44,7 +52,10 @@ export async function loader({ params }: Route.LoaderArgs) {
);
}
const file = await getFile(JSON.parse(settings.brandingLogo)).catch(() => null);
const file = await getFileServerSide(JSON.parse(settings.brandingLogo)).catch((e) => {
console.log('@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@');
console.error(e);
});
if (!file) {
return Response.json(
@@ -0,0 +1,66 @@
import { useLayoutEffect, useState } from 'react';
import { Outlet } from 'react-router';
import { TrpcProvider, trpc } from '@documenso/trpc/react';
import { EmbedClientLoading } from '~/components/embed/embed-client-loading';
import { ZBaseEmbedAuthoringSchema } from '~/types/embed-authoring-base-schema';
import { injectCss } from '~/utils/css-vars';
export default function AuthoringLayout() {
const [token, setToken] = useState('');
const {
mutateAsync: verifyEmbeddingPresignToken,
isPending: isVerifyingEmbeddingPresignToken,
data: isVerified,
} = trpc.embeddingPresign.verifyEmbeddingPresignToken.useMutation();
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
const result = ZBaseEmbedAuthoringSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
if (!result.success) {
return;
}
const { token, css, cssVars, darkModeDisabled } = result.data;
if (darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
}
injectCss({
css,
cssVars,
});
void verifyEmbeddingPresignToken({ token }).then((result) => {
if (result.success) {
setToken(token);
}
});
} catch (err) {
console.error('Error verifying embedding presign token:', err);
}
}, []);
if (isVerifyingEmbeddingPresignToken) {
return <EmbedClientLoading />;
}
if (typeof isVerified !== 'undefined' && !isVerified.success) {
return <div>Invalid embedding presign token</div>;
}
return (
<TrpcProvider headers={{ authorization: `Bearer ${token}` }}>
<Outlet />
</TrpcProvider>
);
}
@@ -0,0 +1,53 @@
import { useEffect } from 'react';
import { Trans } from '@lingui/react/macro';
import { CheckCircle2 } from 'lucide-react';
import { useSearchParams } from 'react-router';
export default function EmbeddingAuthoringCompletedPage() {
const [searchParams] = useSearchParams();
// Get templateId and externalId from URL search params
const templateId = searchParams.get('templateId');
const documentId = searchParams.get('documentId');
const externalId = searchParams.get('externalId');
const id = Number(templateId || documentId);
const type = templateId ? 'template' : 'document';
// Send postMessage to parent window with the details
useEffect(() => {
if (!id || !window.parent || window.parent === window) {
return;
}
window.parent.postMessage(
{
type: `${type}-created`,
[`${type}Id`]: id,
externalId,
},
'*',
);
}, [id, type, externalId]);
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center p-6 text-center">
<div className="mx-auto w-full max-w-md">
<CheckCircle2 className="text-primary mx-auto h-16 w-16" />
<h1 className="mt-6 text-2xl font-bold">
{type === 'template' ? <Trans>Template Created</Trans> : <Trans>Document Created</Trans>}
</h1>
<p className="text-muted-foreground mt-2">
{type === 'template' ? (
<Trans>Your template has been created successfully</Trans>
) : (
<Trans>Your document has been created successfully</Trans>
)}
</p>
</div>
</div>
);
}
@@ -0,0 +1,184 @@
import { useLayoutEffect, useState } from 'react';
import { useLingui } from '@lingui/react';
import { useNavigate } from 'react-router';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
import {
ConfigureFieldsView,
type TConfigureFieldsFormSchema,
} from '~/components/embed/authoring/configure-fields-view';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringSchema,
} from '~/types/embed-authoring-base-schema';
export default function EmbeddingAuthoringDocumentCreatePage() {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(null);
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(null);
const [features, setFeatures] = useState<TBaseEmbedAuthoringSchema['features'] | null>(null);
const [externalId, setExternalId] = useState<string | null>(null);
const [currentStep, setCurrentStep] = useState(1);
const { mutateAsync: createEmbeddingDocument } =
trpc.embeddingPresign.createEmbeddingDocument.useMutation();
const handleConfigurePageViewSubmit = (data: TConfigureEmbedFormSchema) => {
// Store the configuration data and move to the field placement stage
setConfiguration(data);
setCurrentStep(2);
};
const handleBackToConfig = (data: TConfigureFieldsFormSchema) => {
// Return to the configuration view but keep the data
setFields(data);
setCurrentStep(1);
};
const handleConfigureFieldsSubmit = async (data: TConfigureFieldsFormSchema) => {
try {
if (!configuration || !configuration.documentData) {
toast({
variant: 'destructive',
title: _('Error'),
description: _('Please configure the document first'),
});
return;
}
const fields = data.fields;
const documentData = await putPdfFile({
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
name: configuration.documentData.name,
type: configuration.documentData.type,
});
// Use the externalId from the URL fragment if available
const documentExternalId = externalId || configuration.meta.externalId;
const createResult = await createEmbeddingDocument({
title: configuration.title,
documentDataId: documentData.id,
externalId: documentExternalId,
meta: {
...configuration.meta,
drawSignatureEnabled:
configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.DRAW),
typedSignatureEnabled:
configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled:
configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD),
},
recipients: configuration.signers.map((signer) => ({
name: signer.name,
email: signer.email,
role: signer.role,
fields: fields
.filter((field) => field.signerEmail === signer.email)
// There's a gnarly discriminated union that makes this hard to satisfy, we're casting for the second
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map<any>((f) => ({
...f,
pageX: f.pageX,
pageY: f.pageY,
width: f.pageWidth,
height: f.pageHeight,
})),
})),
});
toast({
title: _('Success'),
description: _('Document created successfully'),
});
// Send a message to the parent window with the document details
if (window.parent !== window) {
window.parent.postMessage(
{
type: 'document-created',
documentId: createResult.documentId,
externalId: documentExternalId,
},
'*',
);
}
const hash = window.location.hash.slice(1);
// Navigate to the completion page instead of the document details page
await navigate(
`/embed/v1/authoring/create-completed?documentId=${createResult.documentId}&externalId=${documentExternalId}#${hash}`,
);
} catch (err) {
console.error('Error creating document:', err);
toast({
variant: 'destructive',
title: _('Error'),
description: _('Failed to create document'),
});
}
};
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
const result = ZBaseEmbedAuthoringSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
if (!result.success) {
return;
}
setFeatures(result.data.features);
// Extract externalId from the parsed data if available
if (result.data.externalId) {
setExternalId(result.data.externalId);
}
} catch (err) {
console.error('Error parsing embedding params:', err);
}
}, []);
return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg p-6">
<ConfigureDocumentProvider isTemplate={false} features={features ?? {}}>
<Stepper currentStep={currentStep} setCurrentStep={setCurrentStep}>
<ConfigureDocumentView
defaultValues={configuration ?? undefined}
onSubmit={handleConfigurePageViewSubmit}
/>
<ConfigureFieldsView
configData={configuration!}
defaultValues={fields ?? undefined}
onBack={handleBackToConfig}
onSubmit={handleConfigureFieldsSubmit}
/>
</Stepper>
</ConfigureDocumentProvider>
</div>
);
}
@@ -0,0 +1,175 @@
import { useLayoutEffect, useState } from 'react';
import { useLingui } from '@lingui/react';
import { useNavigate } from 'react-router';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
import {
ConfigureFieldsView,
type TConfigureFieldsFormSchema,
} from '~/components/embed/authoring/configure-fields-view';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringSchema,
} from '~/types/embed-authoring-base-schema';
export default function EmbeddingAuthoringTemplateCreatePage() {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(null);
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(null);
const [features, setFeatures] = useState<TBaseEmbedAuthoringSchema['features'] | null>(null);
const [externalId, setExternalId] = useState<string | null>(null);
const [currentStep, setCurrentStep] = useState(1);
const { mutateAsync: createEmbeddingTemplate } =
trpc.embeddingPresign.createEmbeddingTemplate.useMutation();
const handleConfigurePageViewSubmit = (data: TConfigureEmbedFormSchema) => {
// Store the configuration data and move to the field placement stage
setConfiguration(data);
setCurrentStep(2);
};
const handleBackToConfig = (data: TConfigureFieldsFormSchema) => {
// Return to the configuration view but keep the data
setFields(data);
setCurrentStep(1);
};
const handleConfigureFieldsSubmit = async (data: TConfigureFieldsFormSchema) => {
try {
console.log('configuration', configuration);
console.log('data', data);
if (!configuration || !configuration.documentData) {
toast({
variant: 'destructive',
title: _('Error'),
description: _('Please configure the template first'),
});
return;
}
const fields = data.fields;
const documentData = await putPdfFile({
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
name: configuration.documentData.name,
type: configuration.documentData.type,
});
// Use the externalId from the URL fragment if available
const metaWithExternalId = {
...configuration.meta,
externalId: externalId || configuration.meta.externalId,
};
const createResult = await createEmbeddingTemplate({
title: configuration.title,
documentDataId: documentData.id,
meta: metaWithExternalId,
recipients: configuration.signers.map((signer) => ({
name: signer.name,
email: signer.email,
role: signer.role,
fields: fields
.filter((field) => field.signerEmail === signer.email)
// There's a gnarly discriminated union that makes this hard to satisfy, we're casting for the second
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map<any>((field) => ({
...field,
pageX: field.pageX,
pageY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
})),
})),
});
toast({
title: _('Success'),
description: _('Template created successfully'),
});
// Send a message to the parent window with the template details
if (window.parent !== window) {
window.parent.postMessage(
{
type: 'template-created',
templateId: createResult.templateId,
externalId: metaWithExternalId.externalId,
},
'*',
);
}
const hash = window.location.hash.slice(1);
// Navigate to the completion page instead of the template details page
await navigate(
`/embed/v1/authoring/create-completed?templateId=${createResult.templateId}&externalId=${metaWithExternalId.externalId}#${hash}`,
);
} catch (err) {
console.error('Error creating template:', err);
toast({
variant: 'destructive',
title: _('Error'),
description: _('Failed to create template'),
});
}
};
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
const result = ZBaseEmbedAuthoringSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
if (!result.success) {
return;
}
setFeatures(result.data.features);
// Extract externalId from the parsed data if available
if (result.data.externalId) {
setExternalId(result.data.externalId);
}
} catch (err) {
console.error('Error parsing embedding params:', err);
}
}, []);
return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg p-6">
<ConfigureDocumentProvider isTemplate={true} features={features ?? {}}>
<Stepper currentStep={currentStep} setCurrentStep={setCurrentStep}>
<ConfigureDocumentView
defaultValues={configuration ?? undefined}
onSubmit={handleConfigurePageViewSubmit}
/>
<ConfigureFieldsView
configData={configuration!}
defaultValues={fields ?? undefined}
onBack={handleBackToConfig}
onSubmit={handleConfigureFieldsSubmit}
/>
</Stepper>
</ConfigureDocumentProvider>
</div>
);
}
@@ -0,0 +1,23 @@
import { z } from 'zod';
import { ZBaseEmbedDataSchema } from './embed-base-schemas';
export const ZBaseEmbedAuthoringSchema = z
.object({
token: z.string(),
externalId: z.string().optional(),
features: z
.object({
allowConfigureSignatureTypes: z.boolean().optional(),
allowConfigureLanguage: z.boolean().optional(),
allowConfigureDateFormat: z.boolean().optional(),
allowConfigureTimezone: z.boolean().optional(),
allowConfigureRedirectUrl: z.boolean().optional(),
allowConfigureCommunication: z.boolean().optional(),
})
.optional()
.default({}),
})
.and(ZBaseEmbedDataSchema);
export type TBaseEmbedAuthoringSchema = z.infer<typeof ZBaseEmbedAuthoringSchema>;
+1 -1
View File
@@ -100,5 +100,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.10.0-rc.4"
"version": "1.10.0-rc.5"
}
+10 -3
View File
@@ -2,6 +2,7 @@ import { lingui } from '@lingui/vite-plugin';
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import serverAdapter from 'hono-react-router-adapter/vite';
import path from 'node:path';
import tailwindcss from 'tailwindcss';
import { defineConfig } from 'vite';
import macrosPlugin from 'vite-plugin-babel-macros';
@@ -44,9 +45,15 @@ export default defineConfig({
resolve: {
alias: {
https: 'node:https',
'.prisma/client/default': '../../node_modules/.prisma/client/default.js',
'.prisma/client/index-browser': '../../node_modules/.prisma/client/index-browser.js',
canvas: './app/types/empty-module.ts',
'.prisma/client/default': path.resolve(
__dirname,
'../../node_modules/.prisma/client/default.js',
),
'.prisma/client/index-browser': path.resolve(
__dirname,
'../../node_modules/.prisma/client/index-browser.js',
),
canvas: path.resolve(__dirname, './app/types/empty-module.ts'),
},
},
/**
+13 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.10.0-rc.4",
"version": "1.10.0-rc.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.10.0-rc.4",
"version": "1.10.0-rc.5",
"workspaces": [
"apps/*",
"packages/*"
@@ -95,7 +95,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.10.0-rc.4",
"version": "1.10.0-rc.5",
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",
@@ -41560,6 +41560,7 @@
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",
"jose": "^6.0.0",
"kysely": "0.26.3",
"luxon": "^3.4.0",
"micro": "^10.0.1",
@@ -41582,6 +41583,15 @@
"@types/pg": "^8.11.4"
}
},
"packages/lib/node_modules/jose": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.10.tgz",
"integrity": "sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"packages/prettier-config": {
"name": "@documenso/prettier-config",
"version": "0.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.10.0-rc.4",
"version": "1.10.0-rc.5",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@@ -18,7 +18,7 @@
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed",
"dx:up": "docker compose -f docker/development/compose.yml up -d",
"dx:down": "docker compose -f docker/development/compose.yml down",
"ci": "turbo run test:e2e",
"ci": "turbo run build --filter=@documenso/remix && turbo run test:e2e",
"prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma",
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
@@ -0,0 +1,203 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { seedUser } from '@documenso/prisma/seed/users';
test.describe('Embedding Presign API', () => {
test('createEmbeddingPresignToken: should create a token with default expiration', async ({
request,
}) => {
const user = await seedUser();
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
},
},
);
const responseData = await response.json();
console.log(responseData);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.token).toBeDefined();
expect(responseData.expiresAt).toBeDefined();
expect(responseData.expiresIn).toBe(3600); // Default 1 hour in seconds
});
test('createEmbeddingPresignToken: should create a token with custom expiration', async ({
request,
}) => {
const user = await seedUser();
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
expiresIn: 120, // 2 hours
},
},
);
const responseData = await response.json();
console.log(responseData);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.token).toBeDefined();
expect(responseData.expiresAt).toBeDefined();
expect(responseData.expiresIn).toBe(7200); // 2 hours in seconds
});
test.skip('createEmbeddingPresignToken: should create a token with immediate expiration in dev mode', async ({
request,
}) => {
const user = await seedUser();
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
expiresIn: 0, // Immediate expiration
},
},
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
const responseData = await response.json();
console.log(responseData);
expect(responseData.token).toBeDefined();
expect(responseData.expiresAt).toBeDefined();
expect(responseData.expiresIn).toBe(0); // 0 seconds
});
test('verifyEmbeddingPresignToken: should verify a valid token', async ({ request }) => {
const user = await seedUser();
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test',
expiresIn: null,
});
// First create a token
const createResponse = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
},
},
);
expect(createResponse.ok()).toBeTruthy();
const createResponseData = await createResponse.json();
console.log('Create response:', createResponseData);
const presignToken = createResponseData.token;
// Then verify it
const verifyResponse = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
token: presignToken,
},
},
);
expect(verifyResponse.ok()).toBeTruthy();
expect(verifyResponse.status()).toBe(200);
const verifyResponseData = await verifyResponse.json();
console.log('Verify response:', verifyResponseData);
expect(verifyResponseData.success).toBe(true);
});
test('verifyEmbeddingPresignToken: should reject an invalid token', async ({ request }) => {
const user = await seedUser();
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
token: 'invalid-token',
},
},
);
const responseData = await response.json();
console.log('Invalid token response:', responseData);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.success).toBe(false);
});
});
@@ -7,8 +7,6 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page }) => {
const user = await seedUser();
@@ -343,11 +343,14 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer'
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
// Update next recipient
await page.locator('button').filter({ hasText: 'Update Recipient' }).click();
await page.waitForTimeout(1000);
// Use dialog context to ensure we're targeting the correct form fields
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: 'Update Recipient' }).click();
await dialog.getByLabel('Name').fill('New Signer');
await dialog.getByLabel('Email').fill('new.signer@example.com');
await dialog.getByLabel('Name').fill('New Recipient');
await dialog.getByLabel('Email').fill('new.recipient@example.com');
// Submit and verify completion
await page.getByRole('button', { name: /Continue|Proceed/i }).click();
@@ -374,8 +377,8 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer'
// Second recipient should be the new signer
const updatedSigner = updatedDocument.recipients[1];
expect(updatedSigner.name).toBe('New Signer');
expect(updatedSigner.email).toBe('new.signer@example.com');
expect(updatedSigner.name).toBe('New Recipient');
expect(updatedSigner.email).toBe('new.recipient@example.com');
expect(updatedSigner.signingOrder).toBe(2);
expect(updatedSigner.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(updatedSigner.role).toBe(RecipientRole.SIGNER);
@@ -11,9 +11,8 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
// eslint-disable-next-line turbo/no-undeclared-env-vars
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
@@ -6,9 +6,8 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
// eslint-disable-next-line turbo/no-undeclared-env-vars
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
@@ -57,6 +57,8 @@ test.describe('Signing Certificate Tests', () => {
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass();
await page.waitForTimeout(2500);
// Get the completed document
const completedDocument = await prisma.document.findFirstOrThrow({
where: { id: document.id },
@@ -127,6 +129,8 @@ test.describe('Signing Certificate Tests', () => {
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass();
await page.waitForTimeout(2500);
// Get the completed document
const completedDocument = await prisma.document.findFirstOrThrow({
where: { id: document.id },
@@ -197,6 +201,8 @@ test.describe('Signing Certificate Tests', () => {
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass();
await page.waitForTimeout(2500);
// Get the completed document
const completedDocument = await prisma.document.findFirstOrThrow({
where: { id: document.id },
@@ -7,8 +7,6 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[PUBLIC_PROFILE]: create profile', async ({ page }) => {
const user = await seedUser();
@@ -6,8 +6,6 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: create team', async ({ page }) => {
const user = await seedUser();
@@ -9,8 +9,6 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: check team documents count', async ({ page }) => {
const { team, teamMember2 } = await seedTeamDocuments();
@@ -6,8 +6,6 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: send team email request', async ({ page }) => {
const team = await seedTeam();
@@ -4,8 +4,6 @@ import { seedTeam } from '@documenso/prisma/seed/teams';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: update the default document visibility in the team global settings', async ({
page,
}) => {
@@ -6,8 +6,6 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: update team member role', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
@@ -9,8 +9,6 @@ import {
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: check that default team signature settings are all enabled', async ({ page }) => {
const { team } = await seedTeamDocuments();
@@ -149,8 +147,9 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
await page.getByRole('button', { name: 'Update' }).first().click();
// Wait for finish
await page.getByText('Document preferences updated').waitFor({ state: 'visible' });
await page.waitForTimeout(1000);
const toast = page.locator('li[role="status"][data-state="open"]').first();
await expect(toast).toBeVisible();
await expect(toast.getByText('Document preferences updated', { exact: true })).toBeVisible();
const template = await seedTeamTemplateWithMeta(team);
@@ -5,8 +5,6 @@ import { seedTeam, seedTeamTransfer } from '@documenso/prisma/seed/teams';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
@@ -9,8 +9,6 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = '';
@@ -6,9 +6,8 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
// eslint-disable-next-line turbo/no-undeclared-env-vars
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
@@ -1,7 +1,6 @@
import { expect, test } from '@playwright/test';
import { DocumentDataType, TeamMemberRole } from '@prisma/client';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
@@ -13,23 +12,9 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
const enterprisePriceId = '';
// Create a temporary PDF file for testing
function createTempPdfFile() {
const tempDir = os.tmpdir();
const tempFilePath = path.join(tempDir, 'test.pdf');
// Create a simple PDF file with some content
const pdfContent = Buffer.from(
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj\nxref\n0 4\n0000000000 65535 f\n0000000009 00000 n\n0000000052 00000 n\n0000000101 00000 n\ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n178\n%%EOF',
);
fs.writeFileSync(tempFilePath, new Uint8Array(pdfContent));
return tempFilePath;
}
const EXAMPLE_PDF_PATH = path.join(__dirname, '../../../../assets/example.pdf');
/**
* 1. Create a template with all settings filled out
@@ -313,67 +298,73 @@ test('[TEMPLATE]: should create a document from a template with custom document'
const template = await seedBlankTemplate(user);
// Create a temporary PDF file for upload
const testPdfPath = createTempPdfFile();
const pdfContent = fs.readFileSync(testPdfPath).toString('base64');
try {
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
const pdfContent = fs.readFileSync(EXAMPLE_PDF_PATH).toString('base64');
// Set template title
await page.getByLabel('Title').fill('TEMPLATE_WITH_CUSTOM_DOC');
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Set template title
await page.getByLabel('Title').fill('TEMPLATE_WITH_CUSTOM_DOC');
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Save template' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Use template with custom document
await page.waitForURL('/templates');
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Save template' }).click();
// Enable custom document upload and upload file
await page.getByLabel('Upload custom document').check();
await page.locator('input[type="file"]').setInputFiles(testPdfPath);
// Use template with custom document
await page.waitForURL('/templates');
await page.getByRole('button', { name: 'Use Template' }).click();
// Wait for upload to complete
await expect(page.getByText(path.basename(testPdfPath))).toBeVisible();
// Enable custom document upload and upload file
await page.getByLabel('Upload custom document').check();
// Create document with custom document data
await page.getByRole('button', { name: 'Create as draft' }).click();
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
// Review that the document was created with the custom document data
await page.waitForURL(/documents/);
await fileChooser.setFiles(EXAMPLE_PDF_PATH);
const documentId = Number(page.url().split('/').pop());
// Wait for upload to complete
await expect(page.getByText(path.basename(EXAMPLE_PDF_PATH))).toBeVisible();
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentData: true,
},
});
// Create document with custom document data
await page.getByRole('button', { name: 'Create as draft' }).click();
expect(document.title).toEqual('TEMPLATE_WITH_CUSTOM_DOC');
expect(document.documentData.type).toEqual(DocumentDataType.BYTES_64);
expect(document.documentData.data).toEqual(pdfContent);
expect(document.documentData.initialData).toEqual(pdfContent);
} finally {
// Clean up the temporary file
fs.unlinkSync(testPdfPath);
}
// Review that the document was created with the custom document data
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentData: true,
},
});
expect(document.title).toEqual('TEMPLATE_WITH_CUSTOM_DOC');
expect(document.documentData.type).toEqual(DocumentDataType.BYTES_64);
expect(document.documentData.data).toEqual(pdfContent);
expect(document.documentData.initialData).toEqual(pdfContent);
});
/**
@@ -393,69 +384,73 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
},
});
// Create a temporary PDF file for upload
const testPdfPath = createTempPdfFile();
const pdfContent = fs.readFileSync(testPdfPath).toString('base64');
const pdfContent = fs.readFileSync(EXAMPLE_PDF_PATH).toString('base64');
try {
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set template title
await page.getByLabel('Title').fill('TEAM_TEMPLATE_WITH_CUSTOM_DOC');
// Set template title
await page.getByLabel('Title').fill('TEAM_TEMPLATE_WITH_CUSTOM_DOC');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
await page.getByRole('button', { name: 'Save template' }).click();
// Use template with custom document
await page.waitForURL(`/t/${team.url}/templates`);
await page.getByRole('button', { name: 'Use Template' }).click();
// Use template with custom document
await page.waitForURL(`/t/${team.url}/templates`);
await page.getByRole('button', { name: 'Use Template' }).click();
// Enable custom document upload and upload file
await page.getByLabel('Upload custom document').check();
await page.locator('input[type="file"]').setInputFiles(testPdfPath);
// Enable custom document upload and upload file
await page.getByLabel('Upload custom document').check();
// Wait for upload to complete
await expect(page.getByText(path.basename(testPdfPath))).toBeVisible();
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
// Create document with custom document data
await page.getByRole('button', { name: 'Create as draft' }).click();
await fileChooser.setFiles(EXAMPLE_PDF_PATH);
// Review that the document was created with the custom document data
await page.waitForURL(/documents/);
// Wait for upload to complete
await expect(page.getByText(path.basename(EXAMPLE_PDF_PATH))).toBeVisible();
const documentId = Number(page.url().split('/').pop());
// Create document with custom document data
await page.getByRole('button', { name: 'Create as draft' }).click();
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentData: true,
},
});
// Review that the document was created with the custom document data
await page.waitForURL(/documents/);
expect(document.teamId).toEqual(team.id);
expect(document.title).toEqual('TEAM_TEMPLATE_WITH_CUSTOM_DOC');
expect(document.documentData.type).toEqual(DocumentDataType.BYTES_64);
expect(document.documentData.data).toEqual(pdfContent);
expect(document.documentData.initialData).toEqual(pdfContent);
} finally {
// Clean up the temporary file
fs.unlinkSync(testPdfPath);
}
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentData: true,
},
});
expect(document.teamId).toEqual(team.id);
expect(document.title).toEqual('TEAM_TEMPLATE_WITH_CUSTOM_DOC');
expect(document.documentData.type).toEqual(DocumentDataType.BYTES_64);
expect(document.documentData.data).toEqual(pdfContent);
expect(document.documentData.initialData).toEqual(pdfContent);
});
/**
@@ -23,8 +23,6 @@ const formatTemplatesPath = (teamUrl?: string) =>
const nanoid = customAlphabet('1234567890abcdef', 10);
test.describe.configure({ mode: 'parallel' });
test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
@@ -6,8 +6,6 @@ import { seedTemplate } from '@documenso/prisma/seed/templates';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEMPLATES]: view templates', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
"test:e2e": "NODE_OPTIONS=--experimental-require-module start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
"test:e2e": "NODE_OPTIONS=--experimental-require-module NODE_ENV=test start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
},
"keywords": [],
"author": "",
+3 -2
View File
@@ -16,12 +16,13 @@ ENV_FILES.forEach((file) => {
export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: true,
fullyParallel: false,
workers: '50%',
maxFailures: process.env.CI ? 1 : undefined,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 1,
retries: process.env.CI ? 4 : 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
@@ -6,7 +6,7 @@ import { prisma } from '@documenso/prisma';
import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices';
export type IsDocumentPlatformOptions = Pick<Document, 'id' | 'userId' | 'teamId'>;
export type IsDocumentPlatformOptions = Pick<Document, 'userId' | 'teamId'>;
/**
* Whether the user is platform, or has permission to use platform features on
@@ -14,6 +14,7 @@ import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-s
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
@@ -167,7 +168,9 @@ export const run = async ({
for (const field of fields) {
if (field.inserted) {
await insertFieldInPDF(pdfDoc, field);
document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(pdfDoc, field)
: await insertFieldInPDF(pdfDoc, field);
}
}
+2 -1
View File
@@ -28,6 +28,7 @@
"@lingui/core": "^5.2.0",
"@lingui/macro": "^5.2.0",
"@lingui/react": "^5.2.0",
"jose": "^6.0.0",
"@noble/ciphers": "0.4.0",
"@noble/hashes": "1.3.2",
"@node-rs/bcrypt": "^1.10.0",
@@ -58,4 +59,4 @@
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4"
}
}
}
@@ -22,6 +22,7 @@ import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
import { flattenForm } from '../pdf/flatten-form';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { legacy_insertFieldInPDF } from '../pdf/legacy-insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendCompletedEmail } from './send-completed-email';
@@ -146,7 +147,9 @@ export const sealDocument = async ({
}
for (const field of fields) {
await insertFieldInPDF(doc, field);
document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(doc, field)
: await insertFieldInPDF(doc, field);
}
// Re-flatten post-insertion to handle fields that create arcoFields
@@ -23,6 +23,7 @@ export type UpdateDocumentOptions = {
visibility?: DocumentVisibility | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
useLegacyFieldInsertion?: boolean;
};
requestMetadata: ApiRequestMetadata;
};
@@ -118,6 +119,7 @@ export const updateDocument = async ({
// If no data just return the document since this function is normally chained after a meta update.
if (!data || Object.values(data).length === 0) {
console.log('no data');
return document;
}
@@ -236,7 +238,7 @@ export const updateDocument = async ({
}
// Early return if nothing is required.
if (auditLogs.length === 0) {
if (auditLogs.length === 0 && data.useLegacyFieldInsertion === undefined) {
return document;
}
@@ -254,6 +256,7 @@ export const updateDocument = async ({
title: data.title,
externalId: data.externalId,
visibility: data.visibility as DocumentVisibility,
useLegacyFieldInsertion: data.useLegacyFieldInsertion,
authOptions,
},
});
@@ -0,0 +1,63 @@
import { SignJWT } from 'jose';
import { DateTime } from 'luxon';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { env } from '../../utils/env';
import { getApiTokenByToken } from '../public-api/get-api-token-by-token';
export type CreateEmbeddingPresignTokenOptions = {
apiToken: string;
/**
* Number of hours until the token expires
* In development mode, can be set to 0 to create a token that expires immediately (for testing)
*/
expiresIn?: number;
};
export const createEmbeddingPresignToken = async ({
apiToken,
expiresIn,
}: CreateEmbeddingPresignTokenOptions) => {
try {
// Validate the API token
const validatedToken = await getApiTokenByToken({ token: apiToken });
const now = DateTime.now();
// In development mode, allow setting expiresIn to 0 for testing
// In production, enforce a minimum expiration time
const isDevelopment = env('NODE_ENV') !== 'production';
console.log('isDevelopment', isDevelopment);
const minExpirationMinutes = isDevelopment ? 0 : 5;
// Ensure expiresIn is at least the minimum allowed value
const effectiveExpiresIn =
expiresIn !== undefined && expiresIn >= minExpirationMinutes ? expiresIn : 60; // Default to 1 hour if not specified or below minimum
const expiresAt = now.plus({ minutes: effectiveExpiresIn });
const secret = new TextEncoder().encode(validatedToken.token);
const token = await new SignJWT({
aud: String(validatedToken.teamId ?? validatedToken.userId),
sub: String(validatedToken.id),
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt(now.toJSDate())
.setExpirationTime(expiresAt.toJSDate())
.sign(secret);
return {
token,
expiresAt: expiresAt.toJSDate(),
expiresIn: Math.floor(expiresAt.diff(now).toMillis() / 1000),
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to create presign token',
});
}
};
@@ -0,0 +1,115 @@
import type { JWTPayload } from 'jose';
import { decodeJwt, jwtVerify } from 'jose';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type VerifyEmbeddingPresignTokenOptions = {
token: string;
};
export const verifyEmbeddingPresignToken = async ({
token,
}: VerifyEmbeddingPresignTokenOptions) => {
// First decode the JWT to get the claims without verification
let decodedToken: JWTPayload;
try {
decodedToken = decodeJwt<JWTPayload>(token);
} catch (error) {
console.error('Error decoding JWT token:', error);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token format',
});
}
// Validate the required claims
if (!decodedToken.sub || typeof decodedToken.sub !== 'string') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token format: missing or invalid subject claim',
});
}
if (!decodedToken.aud || typeof decodedToken.aud !== 'string') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token format: missing or invalid audience claim',
});
}
// Convert string IDs to numbers
const tokenId = Number(decodedToken.sub);
const audienceId = Number(decodedToken.aud);
if (Number.isNaN(tokenId) || !Number.isInteger(tokenId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid token ID format in subject claim',
});
}
if (Number.isNaN(audienceId) || !Number.isInteger(audienceId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid user ID format in audience claim',
});
}
// Get the API token to use as the verification secret
const apiToken = await prisma.apiToken.findFirst({
where: {
id: tokenId,
},
});
if (!apiToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token not found',
});
}
// This should never happen but we need to narrow types
if (!apiToken.userId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token does not have a user attached',
});
}
const userId = apiToken.userId;
if (audienceId !== apiToken.teamId && audienceId !== apiToken.userId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token does not match audience',
});
}
// Now verify the token with the actual secret
const secret = new TextEncoder().encode(apiToken.token);
try {
await jwtVerify(token, secret);
} catch (error) {
// Check if the token has expired
if (error instanceof Error && error.name === 'JWTExpired') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Presign token has expired',
});
}
// Handle invalid signature
if (error instanceof Error && error.name === 'JWSInvalid') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token signature',
});
}
// Log and rethrow other errors
console.error('Error verifying JWT token:', error);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Failed to verify presign token',
});
}
return {
...apiToken,
userId,
};
};
@@ -1,8 +1,8 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { FieldType } from '@prisma/client';
import type { PDFDocument } from 'pdf-lib';
import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib';
import type { PDFDocument, PDFFont } from 'pdf-lib';
import { RotationTypes, TextAlignment, degrees, radiansToDegrees, rgb } from 'pdf-lib';
import { P, match } from 'ts-pattern';
import {
@@ -34,6 +34,13 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
]);
const isSignatureField = isSignatureFieldType(field.type);
/**
* Red box is the original field width, height and position.
*
* Blue box is the adjusted field width, height and position. It will represent
* where the text will overflow into.
*/
const isDebugMode =
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
@@ -227,8 +234,13 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const selected: string[] = fromCheckboxValue(field.customText);
const topPadding = 12;
const leftCheckboxPadding = 8;
const leftCheckboxLabelPadding = 12;
const checkboxSpaceY = 13;
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const offsetY = index * checkboxSpaceY + topPadding;
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
@@ -237,7 +249,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
}
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
x: fieldX + leftCheckboxPadding + leftCheckboxLabelPadding,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
@@ -245,7 +257,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
});
checkbox.addToPage(page, {
x: fieldX,
x: fieldX + leftCheckboxPadding,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
@@ -268,21 +280,28 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const selected = field.customText.split(',');
const topPadding = 12;
const leftRadioPadding = 8;
const leftRadioLabelPadding = 12;
const radioSpaceY = 13;
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const offsetY = index * radioSpaceY + topPadding;
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
// Draw label.
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
x: fieldX + leftRadioPadding + leftRadioLabelPadding,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
// Draw radio button.
radio.addOptionToPage(item.value, page, {
x: fieldX,
x: fieldX + leftRadioPadding,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
@@ -304,62 +323,144 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
} as const;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const Parser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
const fieldMetaParser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
const meta = fieldMetaParser ? fieldMetaParser.safeParse(field.fieldMeta) : null;
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'left';
let fontSize = customFontSize || maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const textHeight = font.heightAtSize(fontSize);
// Scale font only if no custom font and height exceeds field height.
if (!customFontSize) {
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
const scalingFactor = Math.min(fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
}
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
/**
* Calculate whether the field should be multiline.
*
* - True = text will overflow downwards.
* - False = text will overflow sideways.
*/
const isMultiline =
field.type === FieldType.TEXT &&
(textWidth > fieldWidth || field.customText.includes('\n'));
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
const padding = 8; // PDF points, roughly equivalent to 0.5rem
const padding = 8;
// Calculate X position based on text alignment with padding
let textX = fieldX + padding; // Left alignment starts after padding
if (textAlign === 'center') {
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
} else if (textAlign === 'right') {
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
}
let textY = fieldY + (fieldHeight - textHeight) / 2;
const textAlignmentOptions = getTextAlignmentOptions(textAlign, fieldX, isMultiline, padding);
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
let textFieldBoxY = pageHeight - fieldY - fieldHeight;
const textFieldBoxX = textAlignmentOptions.xPos;
const textField = pdf.getForm().createTextField(`text.${field.secondaryId}`);
textField.setAlignment(textAlignmentOptions.textAlignment);
/**
* From now on we will adjust the field size and position so the text
* overflows correctly in the X or Y axis depending on the field type.
*/
let adjustedFieldWidth = fieldWidth - padding * 2; //
let adjustedFieldHeight = fieldHeight;
let adjustedFieldX = textFieldBoxX;
let adjustedFieldY = textFieldBoxY;
let textToInsert = field.customText;
// The padding to use when fields go off the page.
const pagePadding = 4;
// Handle multiline text, which will overflow on the Y axis.
if (isMultiline) {
textToInsert = breakLongString(textToInsert, adjustedFieldWidth, font, fontSize);
textField.enableMultiline();
textField.disableCombing();
textField.disableScrolling();
// Adjust the textFieldBox so it extends to the bottom of the page so text can wrap.
textFieldBoxY = pageHeight - fieldY - fieldHeight;
// Calculate how much PX from the current field to bottom of the page.
const fieldYOffset = pageHeight - (fieldY + fieldHeight) - pagePadding;
// Field height will be from current to bottom of page.
adjustedFieldHeight = fieldHeight + fieldYOffset;
// Need to move the field Y so it offsets the new field height.
adjustedFieldY = adjustedFieldY - fieldYOffset;
}
// Handle non-multiline text, which will overflow on the X axis.
if (!isMultiline) {
// Left align will extend all the way to the right of the page
if (textAlignmentOptions.textAlignment === TextAlignment.Left) {
adjustedFieldWidth = pageWidth - textFieldBoxX - pagePadding;
}
// Right align will extend all the way to the left of the page.
if (textAlignmentOptions.textAlignment === TextAlignment.Right) {
adjustedFieldWidth = textFieldBoxX + fieldWidth - pagePadding;
adjustedFieldX = adjustedFieldX - adjustedFieldWidth + fieldWidth;
}
// Center align will extend to the closest page edge, then use that * 2 as the width.
if (textAlignmentOptions.textAlignment === TextAlignment.Center) {
const fieldMidpoint = textFieldBoxX + fieldWidth / 2;
const isCloserToLeftEdge = fieldMidpoint < pageWidth / 2;
// If field is closer to left edge, the width must be based of the left.
if (isCloserToLeftEdge) {
adjustedFieldWidth = (textFieldBoxX - pagePadding) * 2 + fieldWidth;
adjustedFieldX = pagePadding;
}
// If field is closer to right edge, the width must be based of the right
if (!isCloserToLeftEdge) {
adjustedFieldWidth = (pageWidth - textFieldBoxX - pagePadding - fieldWidth / 2) * 2;
adjustedFieldX = pageWidth - adjustedFieldWidth - pagePadding;
}
}
}
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
adjustedFieldX,
adjustedFieldY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
adjustedFieldX = adjustedPosition.xPos;
adjustedFieldY = adjustedPosition.yPos;
}
page.drawText(field.customText, {
x: textX,
y: textY,
size: fontSize,
font,
// Set the position and size of the text field
textField.addToPage(page, {
x: adjustedFieldX,
y: adjustedFieldY,
width: adjustedFieldWidth,
height: adjustedFieldHeight,
rotate: degrees(pageRotationInDegrees),
// Hide borders.
borderWidth: 0,
borderColor: undefined,
backgroundColor: undefined,
...(isDebugMode ? { borderWidth: 1, borderColor: rgb(0, 0, 1) } : {}),
});
// Set properties for the text field
textField.setFontSize(fontSize);
textField.setText(textToInsert);
});
return pdf;
@@ -393,3 +494,138 @@ const adjustPositionForRotation = (
yPos,
};
};
const textAlignmentMap = {
left: TextAlignment.Left,
center: TextAlignment.Center,
right: TextAlignment.Right,
} as const;
/**
* Get the PDF-lib alignment position, and the X position of the field with padding included.
*
* @param textAlign - The text alignment of the field.
* @param fieldX - The X position of the field.
* @param isMultiline - Whether the field is multiline.
* @param padding - The padding of the field. Defaults to 8.
*
* @returns The X position and text alignment for the field.
*/
const getTextAlignmentOptions = (
textAlign: 'left' | 'center' | 'right',
fieldX: number,
isMultiline: boolean,
padding: number = 8,
) => {
const textAlignment = textAlignmentMap[textAlign];
// For multiline, it needs to be centered so we just basic left padding.
if (isMultiline) {
return {
xPos: fieldX + padding,
textAlignment,
};
}
return match(textAlign)
.with('left', () => ({
xPos: fieldX + padding,
textAlignment,
}))
.with('center', () => ({
xPos: fieldX,
textAlignment,
}))
.with('right', () => ({
xPos: fieldX - padding,
textAlignment,
}))
.exhaustive();
};
/**
* Break a long string into multiple lines so it fits within a given width,
* using natural word breaking similar to word processors.
*
* - Keeps words together when possible
* - Only breaks words when they're too long to fit on a line
* - Handles whitespace intelligently
*
* @param text - The text to break into lines
* @param maxWidth - The maximum width of each line in PX
* @param font - The PDF font object
* @param fontSize - The font size in points
* @returns Object containing the result string and line count
*/
function breakLongString(text: string, maxWidth: number, font: PDFFont, fontSize: number): string {
// Handle empty text
if (!text) {
return '';
}
const lines: string[] = [];
// Process each original line separately to preserve newlines
for (const paragraph of text.split('\n')) {
// If paragraph fits on one line or is empty, add it as-is
if (paragraph === '' || font.widthOfTextAtSize(paragraph, fontSize) <= maxWidth) {
lines.push(paragraph);
continue;
}
// Split paragraph into words
const words = paragraph.split(' ');
let currentLine = '';
for (const word of words) {
// Check if adding word to current line would exceed max width
const lineWithWord = currentLine.length === 0 ? word : `${currentLine} ${word}`;
if (font.widthOfTextAtSize(lineWithWord, fontSize) <= maxWidth) {
// Word fits, add it to current line
currentLine = lineWithWord;
} else {
// Word doesn't fit on current line
// First, save current line if it's not empty
if (currentLine.length > 0) {
lines.push(currentLine);
currentLine = '';
}
// Check if word fits on a line by itself
if (font.widthOfTextAtSize(word, fontSize) <= maxWidth) {
// Word fits on its own line
currentLine = word;
} else {
// Word is too long, need to break it character by character
let charLine = '';
// Process each character in the word
for (const char of word) {
const nextCharLine = charLine + char;
if (font.widthOfTextAtSize(nextCharLine, fontSize) <= maxWidth) {
// Character fits, add it
charLine = nextCharLine;
} else {
// Character doesn't fit, push current charLine and start a new one
lines.push(charLine);
charLine = char;
}
}
// Add any remaining characters as the current line
currentLine = charLine;
}
}
}
// Add the last line if not empty
if (currentLine.length > 0) {
lines.push(currentLine);
}
}
return lines.join('\n');
}
@@ -0,0 +1,395 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { FieldType } from '@prisma/client';
import type { PDFDocument } from 'pdf-lib';
import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib';
import { P, match } from 'ts-pattern';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
DEFAULT_STANDARD_FONT_SIZE,
MIN_HANDWRITING_FONT_SIZE,
MIN_STANDARD_FONT_SIZE,
} from '@documenso/lib/constants/pdf';
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
ZCheckboxFieldMeta,
ZDateFieldMeta,
ZEmailFieldMeta,
ZInitialsFieldMeta,
ZNameFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
]);
const isSignatureField = isSignatureFieldType(field.type);
const isDebugMode =
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
pdf.registerFontkit(fontkit);
const pages = pdf.getPages();
const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
const page = pages.at(field.page - 1);
if (!page) {
throw new Error(`Page ${field.page} does not exist`);
}
const pageRotation = page.getRotation();
let pageRotationInDegrees = match(pageRotation.type)
.with(RotationTypes.Degrees, () => pageRotation.angle)
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
.exhaustive();
// Round to the closest multiple of 90 degrees.
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
//
// To account for this, we swap the width and height for pages that are rotated by 90/270
// degrees. This is so we can calculate the virtual position the field was placed if it
// was correctly oriented in the frontend.
//
// Then when we insert the fields, we apply a transformation to the position of the field
// so it is rotated correctly.
if (isPageRotatedToLandscape) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
}
const fieldWidth = pageWidth * (Number(field.width) / 100);
const fieldHeight = pageHeight * (Number(field.height) / 100);
const fieldX = pageWidth * (Number(field.positionX) / 100);
const fieldY = pageHeight * (Number(field.positionY) / 100);
// Draw debug box if debug mode is enabled
if (isDebugMode) {
let debugX = fieldX;
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
debugX,
debugY,
pageRotationInDegrees,
);
debugX = adjustedPosition.xPos;
debugY = adjustedPosition.yPos;
}
page.drawRectangle({
x: debugX,
y: debugY,
width: fieldWidth,
height: fieldHeight,
borderColor: rgb(1, 0, 0), // Red
borderWidth: 1,
rotate: degrees(pageRotationInDegrees),
});
}
const font = await pdf.embedFont(
isSignatureField ? fontCaveat : fontNoto,
isSignatureField ? { features: { calt: false } } : undefined,
);
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
await pdf.embedFont(fontCaveat);
}
await match(field)
.with(
{
type: P.union(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE),
},
async (field) => {
if (field.signature?.signatureImageAsBase64) {
const image = await pdf.embedPng(field.signature?.signatureImageAsBase64 ?? '');
let imageWidth = image.width;
let imageHeight = image.height;
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
imageY = pageHeight - imageY - imageHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
imageX,
imageY,
pageRotationInDegrees,
);
imageX = adjustedPosition.xPos;
imageY = adjustedPosition.yPos;
}
page.drawImage(image, {
x: imageX,
y: imageY,
width: imageWidth,
height: imageHeight,
rotate: degrees(pageRotationInDegrees),
});
} else {
const signatureText = field.signature?.typedSignature ?? '';
const longestLineInTextForWidth = signatureText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let fontSize = maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
let textHeight = font.heightAtSize(fontSize);
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
textHeight = font.heightAtSize(fontSize);
let textX = fieldX + (fieldWidth - textWidth) / 2;
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
}
page.drawText(signatureText, {
x: textX,
y: textY,
size: fontSize,
font,
rotate: degrees(pageRotationInDegrees),
});
}
},
)
.with({ type: FieldType.CHECKBOX }, (field) => {
const meta = ZCheckboxFieldMeta.safeParse(field.fieldMeta);
if (!meta.success) {
console.error(meta.error);
throw new Error('Invalid checkbox field meta');
}
const values = meta.data.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const selected: string[] = fromCheckboxValue(field.customText);
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
if (selected.includes(item.value)) {
checkbox.check();
}
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
checkbox.addToPage(page, {
x: fieldX,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
}
})
.with({ type: FieldType.RADIO }, (field) => {
const meta = ZRadioFieldMeta.safeParse(field.fieldMeta);
if (!meta.success) {
console.error(meta.error);
throw new Error('Invalid radio field meta');
}
const values = meta?.data.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const selected = field.customText.split(',');
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
radio.addOptionToPage(item.value, page, {
x: fieldX,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
if (selected.includes(item.value)) {
radio.select(item.value);
}
}
})
.otherwise((field) => {
const fieldMetaParsers = {
[FieldType.TEXT]: ZTextFieldMeta,
[FieldType.NUMBER]: ZNumberFieldMeta,
[FieldType.DATE]: ZDateFieldMeta,
[FieldType.EMAIL]: ZEmailFieldMeta,
[FieldType.NAME]: ZNameFieldMeta,
[FieldType.INITIALS]: ZInitialsFieldMeta,
} as const;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const Parser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let fontSize = customFontSize || maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textHeight = font.heightAtSize(fontSize);
if (!customFontSize) {
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
}
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
const padding = 8; // PDF points, roughly equivalent to 0.5rem
// Calculate X position based on text alignment with padding
let textX = fieldX + padding; // Left alignment starts after padding
if (textAlign === 'center') {
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
} else if (textAlign === 'right') {
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
}
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
}
page.drawText(field.customText, {
x: textX,
y: textY,
size: fontSize,
font,
rotate: degrees(pageRotationInDegrees),
});
});
return pdf;
};
const adjustPositionForRotation = (
pageWidth: number,
pageHeight: number,
xPos: number,
yPos: number,
pageRotationInDegrees: number,
) => {
if (pageRotationInDegrees === 270) {
xPos = pageWidth - xPos;
[xPos, yPos] = [yPos, xPos];
}
if (pageRotationInDegrees === 90) {
yPos = pageHeight - yPos;
[xPos, yPos] = [yPos, xPos];
}
// Invert all the positions since it's rotated by 180 degrees.
if (pageRotationInDegrees === 180) {
xPos = pageWidth - xPos;
yPos = pageHeight - yPos;
}
return {
xPos,
yPos,
};
};
+25 -1
View File
@@ -5,6 +5,7 @@ import { z } from 'zod';
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
import { getTeamRelatedPrices } from '@documenso/ee/server-only/stripe/get-team-related-prices';
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
import { isDocumentPlatform as isUserPlatformPlan } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
@@ -60,6 +61,11 @@ export const createTeam = async ({
},
});
const isPlatformPlan = await isUserPlatformPlan({
userId: user.id,
teamId: null,
});
let isPaymentRequired = IS_BILLING_ENABLED();
let customerId: string | null = null;
@@ -68,7 +74,25 @@ export const createTeam = async ({
prices.map((price) => price.id),
);
isPaymentRequired = !subscriptionsContainsActivePlan(user.subscriptions, teamRelatedPriceIds);
const hasTeamRelatedSubscription = subscriptionsContainsActivePlan(
user.subscriptions,
teamRelatedPriceIds,
);
if (isPlatformPlan) {
// For platform users, check if they already have any teams
const existingTeams = await prisma.team.findMany({
where: {
ownerUserId: userId,
},
});
// Payment is required if they already have any team
isPaymentRequired = existingTeams.length > 0;
} else {
// For non-platform users, payment is required if they don't have a team-related subscription
isPaymentRequired = !hasTeamRelatedSubscription;
}
customerId = await createTeamCustomer({
name: user.name ?? teamName,
@@ -77,6 +77,7 @@ export const createDocumentFromTemplateLegacy = async ({
title: template.title,
visibility: template.team?.teamGlobalSettings?.documentVisibility,
documentDataId: documentData.id,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
recipients: {
create: template.recipients.map((recipient) => ({
email: recipient.email,
@@ -384,6 +384,7 @@ export const createDocumentFromTemplate = async ({
globalActionAuth: templateAuthOptions.globalActionAuth,
}),
visibility: template.visibility || template.team?.teamGlobalSettings?.documentVisibility,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
documentMeta: {
create: {
subject: override?.subject || template.templateMeta?.subject,
@@ -20,6 +20,7 @@ export type UpdateTemplateOptions = {
publicTitle?: string;
publicDescription?: string;
type?: Template['type'];
useLegacyFieldInsertion?: boolean;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
};
@@ -102,6 +103,7 @@ export const updateTemplate = async ({
visibility: data?.visibility,
publicDescription: data?.publicDescription,
publicTitle: data?.publicTitle,
useLegacyFieldInsertion: data?.useLegacyFieldInsertion,
authOptions,
templateMeta: {
upsert: {
+89 -89
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-01-30 06:04\n"
"PO-Revision-Date: 2025-03-25 12:05\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -20,11 +20,11 @@ msgstr ""
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid " Enable direct link signing"
msgstr ""
msgstr " Direktlink-Signierung aktivieren"
#: apps/remix/app/routes/_authenticated+/settings+/webhooks.$id.tsx
msgid " The events that will trigger a webhook to be sent to your URL."
msgstr ""
msgstr " Die Ereignisse, die einen Webhook auslösen, der an Ihre URL gesendet wird."
#. placeholder {0}: team.name
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
@@ -35,7 +35,7 @@ msgstr "\"{0}\" hat Sie eingeladen, \"Beispieldokument\" zu unterschreiben."
#. placeholder {1}: timezone || ''
#: apps/remix/app/components/general/document-signing/document-signing-date-field.tsx
msgid "\"{0}\" will appear on the document as it has a timezone of \"{1}\"."
msgstr ""
msgstr "„{0}“ wird auf dem Dokument erscheinen, da es eine Zeitzone von „{1}“ hat."
#: packages/email/template-components/template-document-super-delete.tsx
msgid "\"{documentName}\" has been deleted by an admin."
@@ -61,7 +61,7 @@ msgstr "\"{placeholderEmail}\" im Namen von \"{0}\" hat Sie eingeladen, \"Beispi
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "(You)"
msgstr ""
msgstr "(Du)"
#. placeholder {0}: Math.abs(charactersRemaining)
#: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
@@ -206,7 +206,7 @@ msgstr "{inviterName} hat dich aus dem Dokument<0/>\"{documentName}\" entfernt"
#. placeholder {1}: document.title
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
msgid "{inviterName} on behalf of \"{0}\" has invited you to {recipientActionVerb} the document \"{1}\"."
msgstr ""
msgstr "{inviterName} im Auftrag von \"{0}\" hat Sie eingeladen, das Dokument \"{1}\" {recipientActionVerb}."
#. placeholder {0}: _(actionVerb).toLowerCase()
#: packages/email/template-components/template-document-invite.tsx
@@ -255,7 +255,7 @@ msgstr "{prefix} hat das Dokument geöffnet"
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} prefilled a field"
msgstr ""
msgstr "{prefix} hat ein Feld vorab ausgefüllt"
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} removed a field"
@@ -421,7 +421,7 @@ msgstr "<0>Klicken Sie hier, um hochzuladen</0> oder ziehen Sie die Datei per Dr
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "<0>Drawn</0> - A signature that is drawn using a mouse or stylus."
msgstr ""
msgstr "<0>Gezeichnet</0> - Eine Signatur, die mit einer Maus oder einem Stift gezeichnet wird."
#: packages/ui/primitives/template-flow/add-template-settings.tsx
msgid "<0>Email</0> - The recipient will be emailed the document to sign, approve, etc."
@@ -471,11 +471,11 @@ msgstr "<0>Absender:</0> Alle"
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "<0>Typed</0> - A signature that is typed using a keyboard."
msgstr ""
msgstr "<0>Getippt</0> - Eine Signatur, die mit einer Tastatur getippt wird."
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "<0>Uploaded</0> - A signature that is uploaded from a file."
msgstr ""
msgstr "<0>Hochgeladen</0> - Eine Signatur, die aus einer Datei hochgeladen wird."
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
msgid "<0>You are about to complete approving <1>\"{documentTitle}\"</1>.</0><2/> Are you sure?"
@@ -504,7 +504,7 @@ msgstr "3 Monate"
#: apps/remix/app/components/general/generic-error-layout.tsx
msgid "404 not found"
msgstr ""
msgstr "404 nicht gefunden"
#: apps/remix/app/routes/_profile+/_layout.tsx
msgid "404 Profile not found"
@@ -516,7 +516,7 @@ msgstr "404 Team nicht gefunden"
#: apps/remix/app/components/general/generic-error-layout.tsx
msgid "500 Internal Server Error"
msgstr ""
msgstr "500 Interner Serverfehler"
#: apps/remix/app/components/forms/token.tsx
msgid "6 months"
@@ -913,12 +913,12 @@ msgstr "Erlauben Sie den Dokumentempfängern, direkt an diese E-Mail-Adresse zu
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "Allow signers to dictate next signer"
msgstr ""
msgstr "Unterzeichner können nächsten Unterzeichner bestimmen"
#: packages/ui/primitives/template-flow/add-template-settings.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Allowed Signature Types"
msgstr ""
msgstr "Erlaubte Signaturtypen"
#: apps/remix/app/routes/_authenticated+/settings+/security._index.tsx
msgid "Allows authenticating using biometrics, password managers, hardware keys, etc."
@@ -1046,7 +1046,7 @@ msgstr "Ein Fehler ist beim Entfernen des Feldes aufgetreten."
#: apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx
msgid "An error occurred while removing the selection."
msgstr ""
msgstr "Beim Entfernen der Auswahl ist ein Fehler aufgetreten."
#: apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx
msgid "An error occurred while removing the signature."
@@ -1070,7 +1070,7 @@ msgstr "Beim Senden Ihrer Bestätigungs-E-Mail ist ein Fehler aufgetreten"
#: apps/remix/app/components/general/document-signing/document-signing-date-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx
msgid "An error occurred while signing as assistant."
msgstr ""
msgstr "Beim Unterschreiben als Assistent ist ein Fehler aufgetreten."
#: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx
@@ -1087,7 +1087,7 @@ msgstr "Ein Fehler ist aufgetreten, während das Dokument unterzeichnet wurde."
#: apps/remix/app/components/general/billing-plans.tsx
msgid "An error occurred while trying to create a checkout session."
msgstr ""
msgstr "Ein Fehler ist aufgetreten, während versucht wurde, eine Checkout-Sitzung zu erstellen."
#: apps/remix/app/components/general/template/template-edit-form.tsx
#: apps/remix/app/components/general/document/document-edit-form.tsx
@@ -1108,7 +1108,7 @@ msgstr "Ein Fehler ist aufgetreten, während dein Dokument hochgeladen wurde."
#: apps/remix/app/components/general/generic-error-layout.tsx
msgid "An unexpected error occurred."
msgstr ""
msgstr "Ein unerwarteter Fehler ist aufgetreten."
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
@@ -1197,7 +1197,7 @@ msgstr "Genehmigung"
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Are you sure you want to complete the document? This action cannot be undone. Please ensure that you have completed prefilling all relevant fields before proceeding."
msgstr ""
msgstr "Sind Sie sicher, dass Sie das Dokument abschließen möchten? Diese Aktion kann nicht rückgängig gemacht werden. Bitte stellen Sie sicher, dass Sie alle relevanten Felder vorab ausgefüllt haben, bevor Sie fortfahren."
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
msgid "Are you sure you want to delete this token?"
@@ -1227,44 +1227,44 @@ msgstr "Bist du dir sicher?"
#: packages/lib/constants/recipient-roles.ts
msgid "Assist"
msgstr ""
msgstr "Hilfe"
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: packages/email/template-components/template-document-invite.tsx
msgid "Assist Document"
msgstr ""
msgstr "Dokumentassistenz"
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "Assist with signing"
msgstr ""
msgstr "Unterstützung beim Unterschreiben"
#: packages/lib/constants/recipient-roles.ts
msgid "Assistant"
msgstr ""
msgstr "Assistent"
#: packages/ui/components/recipient/recipient-role-select.tsx
msgid "Assistant role is only available when the document is in sequential signing mode."
msgstr ""
msgstr "Die Rolle des Assistenten ist nur verfügbar, wenn das Dokument im sequentiellen Unterschriftsmodus ist."
#: packages/lib/constants/recipient-roles.ts
msgid "Assistants"
msgstr ""
msgstr "Assistenten"
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: packages/lib/constants/recipient-roles.ts
msgid "Assisted"
msgstr ""
msgstr "Unterstützt"
#: packages/lib/constants/recipient-roles.ts
msgid "Assisting"
msgstr ""
msgstr "Unterstützend"
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
#: packages/ui/primitives/document-flow/add-settings.types.ts
#: packages/lib/types/document-meta.ts
msgid "At least one signature type must be enabled"
msgstr ""
msgstr "Mindestens ein Signaturtyp muss aktiviert sein"
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
msgid "Attempts sealing the document again, useful for after a code change has occurred to resolve an erroneous document."
@@ -1403,7 +1403,7 @@ msgstr "Durch das Löschen dieses Dokuments wird Folgendes passieren:"
#: apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx
msgid "By enabling 2FA, you will be required to enter a code from your authenticator app every time you sign in using email password."
msgstr ""
msgstr "Wenn 2FA aktiviert wird, müssen Sie bei jedem Anmelden mit E-Mail-Passwort einen Code aus Ihrer Authentifikator-App eingeben."
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "By proceeding to use the electronic signature service provided by Documenso, you affirm that you have read and understood this disclosure. You agree to all terms and conditions related to the use of electronic signatures and electronic transactions as outlined herein."
@@ -1415,7 +1415,7 @@ msgstr "Indem Sie fortfahren, Ihre elektronische Unterschrift zu leisten, erkenn
#: apps/remix/app/components/forms/signup.tsx
msgid "By proceeding, you agree to our <0>Terms of Service</0> and <1>Privacy Policy</1>."
msgstr ""
msgstr "Indem Sie fortfahren, stimmen Sie unseren <0>Nutzungsbedingungen</0> und <1>Datenschutzrichtlinien</1> zu."
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "By using the electronic signature feature, you are consenting to conduct transactions and receive disclosures electronically. You acknowledge that your electronic signature on documents is binding and that you accept the terms outlined in the documents you are signing."
@@ -1423,7 +1423,7 @@ msgstr "Durch die Verwendung der elektronischen Unterschriftsfunktion stimmen Si
#: packages/ui/components/recipient/recipient-role-select.tsx
msgid "Can prepare"
msgstr ""
msgstr "Kann vorbereiten"
#: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
#: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
@@ -1616,11 +1616,11 @@ msgstr "Genehmigung abschließen"
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
msgid "Complete Assisting"
msgstr ""
msgstr "Vervollständigen der Assistenz"
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Complete Document"
msgstr ""
msgstr "Dokument abschließen"
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
msgid "Complete Signing"
@@ -1628,7 +1628,7 @@ msgstr "Unterzeichnung abschließen"
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
msgid "Complete the fields for the following signers. Once reviewed, they will inform you if any modifications are needed."
msgstr ""
msgstr "Füllen Sie die Felder für die folgenden Unterzeichner aus. Nach der Überprüfung werden sie Sie informieren, ob Änderungen erforderlich sind."
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
@@ -1743,7 +1743,7 @@ msgstr "Fahre fort, indem du das Dokument genehmigst."
#: packages/email/template-components/template-document-invite.tsx
msgid "Continue by assisting with the document."
msgstr ""
msgstr "Fahren Sie fort, indem Sie das Dokument unterstützen."
#: packages/email/template-components/template-document-completed.tsx
msgid "Continue by downloading the document."
@@ -1775,7 +1775,7 @@ msgstr "Steuert das Format der Nachricht, die gesendet wird, wenn ein Empfänger
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Controls the language for the document, including the language to be used for email notifications, and the final certificate that is generated and attached to the document."
msgstr ""
msgstr "Legt die Sprache des Dokuments fest, einschließlich der Sprache für E-Mail-Benachrichtigungen und des endgültigen Zertifikats, das generiert und dem Dokument angehängt wird."
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
msgid "Controls whether the signing certificate will be included in the document when it is downloaded. The signing certificate can still be downloaded from the logs page separately."
@@ -1783,7 +1783,7 @@ msgstr "Legt fest, ob das Signaturzertifikat in das Dokument aufgenommen wird, w
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
msgid "Controls which signatures are allowed to be used when signing a document."
msgstr ""
msgstr "Bestimmt, welche Signaturen beim Unterschreiben eines Dokuments verwendet werden dürfen."
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
@@ -1975,7 +1975,7 @@ msgstr "Aktuelle Empfänger:"
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Daily"
msgstr ""
msgstr "Täglich"
#: apps/remix/app/components/general/app-command-menu.tsx
msgid "Dark Mode"
@@ -2017,7 +2017,7 @@ msgstr "Standard Sichtbarkeit des Dokuments"
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
msgid "Default Signature Settings"
msgstr ""
msgstr "Standard-Signatureinstellungen"
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
msgid "delete"
@@ -2248,7 +2248,7 @@ msgstr "Dokument \"{0}\" - Ablehnung Bestätigt"
#. placeholder {0}: document.title
#: packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts
msgid "Document \"{0}\" Cancelled"
msgstr ""
msgstr "Dokument „{0}“ abgebrochen"
#: packages/ui/primitives/template-flow/add-template-settings.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
@@ -2400,7 +2400,7 @@ msgstr "Dokument erneut gesendet"
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
msgstr ""
msgstr "Dokument abgelehnt"
#: apps/remix/app/routes/_recipient+/sign.$token+/rejected.tsx
#: apps/remix/app/components/embed/embed-document-rejected.tsx
@@ -2541,7 +2541,7 @@ msgstr "Ziehen Sie Ihr PDF hierher."
#: packages/lib/constants/document.ts
msgid "Draw"
msgstr ""
msgstr "Zeichnen"
#: packages/ui/primitives/template-flow/add-template-fields.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
@@ -2635,7 +2635,7 @@ msgstr "E-Mail-Adresse"
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "Email already confirmed"
msgstr ""
msgstr "E-Mail bereits bestätigt"
#: apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx
msgid "Email cannot already exist in the template"
@@ -2873,7 +2873,7 @@ msgstr "Feldplatzhalter"
#: packages/lib/utils/document-audit-logs.ts
msgid "Field prefilled by assistant"
msgstr ""
msgstr "Feld vorab ausgefüllt durch Assistenten"
#: packages/lib/utils/document-audit-logs.ts
msgid "Field signed"
@@ -2989,7 +2989,7 @@ msgstr "hat Sie eingeladen, dieses Dokument zu genehmigen"
#: apps/remix/app/components/general/document-signing/document-signing-page-view.tsx
msgid "has invited you to assist this document"
msgstr ""
msgstr "hat dich eingeladen, bei diesem Dokument zu assistieren"
#: apps/remix/app/components/general/document-signing/document-signing-page-view.tsx
msgid "has invited you to sign this document"
@@ -3006,11 +3006,11 @@ msgstr "hat Sie eingeladen, dieses Dokument anzusehen"
#: packages/ui/primitives/document-flow/add-signers.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist."
msgstr ""
msgstr "Einen Assistenten als letzten Unterzeichner zu haben bedeutet, dass er keine Aktion vornehmen kann, da es keine nachfolgenden Unterzeichner gibt."
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "Help complete the document for other signers."
msgstr ""
msgstr "Hilfe beim Abschließen des Dokuments für andere Unterzeichner."
#: apps/remix/app/routes/_authenticated+/settings+/profile.tsx
msgid "Here you can edit your personal details."
@@ -3068,7 +3068,7 @@ msgstr "Ich bin ein Genehmiger dieses Dokuments"
#: packages/lib/constants/recipient-roles.ts
msgid "I am an assistant of this document"
msgstr ""
msgstr "Ich bin ein Assistent dieses Dokuments"
#: packages/lib/constants/recipient-roles.ts
msgid "I am required to receive a copy of this document"
@@ -3220,7 +3220,7 @@ msgstr "Es scheint, dass kein Token bereitgestellt wurde. Wenn Sie versuchen, Ih
#: apps/remix/app/components/embed/embed-document-waiting-for-turn.tsx
msgid "It's currently not your turn to sign. Please check back soon as this document should be available for you to sign shortly."
msgstr ""
msgstr "Es ist momentan nicht Ihre Runde zum Unterschreiben. Bitte schauen Sie bald wieder vorbei, da dieses Dokument bald für Sie zum Unterschreiben verfügbar sein sollte."
#: apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx
msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document."
@@ -3369,7 +3369,7 @@ msgstr "Vorlage verwalten und anzeigen"
#: apps/remix/app/routes/_authenticated+/settings+/billing.tsx
msgid "Manage billing"
msgstr ""
msgstr "Rechnungsmanagement"
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
msgid "Manage details for this public template"
@@ -3393,7 +3393,7 @@ msgstr "Abonnement verwalten"
#: apps/remix/app/components/general/billing-portal-button.tsx
msgid "Manage Subscription"
msgstr ""
msgstr "Abonnement verwalten"
#: apps/remix/app/routes/_authenticated+/admin+/subscriptions.tsx
msgid "Manage subscriptions"
@@ -3433,7 +3433,7 @@ msgstr "Manager"
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
msgid "Mark as viewed"
msgstr ""
msgstr "Als gesehen markieren"
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
@@ -3575,7 +3575,7 @@ msgstr "Nie ablaufen"
#: apps/remix/app/components/forms/password.tsx
msgid "New Password"
msgstr ""
msgstr "Neues Passwort"
#: apps/remix/app/components/dialogs/team-transfer-dialog.tsx
msgid "New team owner"
@@ -3709,7 +3709,7 @@ msgstr "im Auftrag von \"{0}\" hat Sie eingeladen, dieses Dokument zu genehmigen
#. placeholder {0}: document.team?.name
#: apps/remix/app/components/general/document-signing/document-signing-page-view.tsx
msgid "on behalf of \"{0}\" has invited you to assist this document"
msgstr ""
msgstr "im Namen von „{0}“ hat dich eingeladen, bei diesem Dokument zu assistieren"
#. placeholder {0}: document.team?.name
#: apps/remix/app/components/general/document-signing/document-signing-page-view.tsx
@@ -3727,7 +3727,7 @@ msgstr "Auf dieser Seite können Sie einen neuen Webhook erstellen."
#: apps/remix/app/routes/_authenticated+/settings+/tokens.tsx
msgid "On this page, you can create and manage API tokens. See our <0>Documentation</0> for more information."
msgstr ""
msgstr "Auf dieser Seite können Sie API-Token erstellen und verwalten. Weitere Informationen finden Sie in unserer <0>Dokumentation</0>."
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
@@ -3979,7 +3979,7 @@ msgstr "Bitte prüfen Sie die CSV-Datei und stellen Sie sicher, dass sie unserem
#: apps/remix/app/components/embed/embed-document-waiting-for-turn.tsx
msgid "Please check with the parent application for more information."
msgstr ""
msgstr "Bitte überprüfen Sie bei der übergeordneten Anwendung weitere Informationen."
#: apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx
msgid "Please check your email for updates."
@@ -4053,7 +4053,7 @@ msgstr "Bitte geben Sie ein Token von Ihrem Authentifizierer oder einen Backup-C
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
msgid "Please review the document before approving."
msgstr ""
msgstr "Bitte überprüfen Sie das Dokument, bevor Sie es genehmigen."
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
msgid "Please review the document before signing."
@@ -4115,7 +4115,7 @@ msgstr "Private Vorlagen können nur von Ihnen bearbeitet und angezeigt werden."
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Proceed"
msgstr ""
msgstr "Fortfahren"
#: apps/remix/app/routes/_authenticated+/settings+/profile.tsx
#: apps/remix/app/components/general/settings-nav-mobile.tsx
@@ -4196,11 +4196,11 @@ msgstr "Grund"
#: packages/email/template-components/template-document-cancel.tsx
msgid "Reason for cancellation: {cancellationReason}"
msgstr ""
msgstr "Stornierungsgrund: {cancellationReason}"
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Reason for rejection: "
msgstr ""
msgstr "Grund für die Ablehnung: "
#: packages/email/template-components/template-document-rejected.tsx
msgid "Reason for rejection: {rejectionReason}"
@@ -4817,15 +4817,15 @@ msgstr "Signatur-ID"
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
msgid "Signature is too small"
msgstr ""
msgstr "Signatur ist zu klein"
#: apps/remix/app/components/forms/profile.tsx
msgid "Signature Pad cannot be empty."
msgstr ""
msgstr "Signaturfeld darf nicht leer sein."
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "Signature types"
msgstr ""
msgstr "Signaturtypen"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Signatures Collected"
@@ -4877,7 +4877,7 @@ msgstr "Unterzeichnung abgeschlossen!"
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "Signing for"
msgstr ""
msgstr "Unterzeichne für"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signin.tsx
@@ -4896,7 +4896,7 @@ msgstr "Unterzeichnungslinks wurden für dieses Dokument erstellt."
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "Signing order is enabled."
msgstr ""
msgstr "Unterzeichnungsreihenfolge ist aktiviert."
#: apps/remix/app/routes/_authenticated+/admin+/leaderboard.tsx
#: apps/remix/app/components/tables/admin-leaderboard-table.tsx
@@ -5051,7 +5051,7 @@ msgstr "Betreff <0>(Optional)</0>"
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Subscribe"
msgstr ""
msgstr "Abonnieren"
#: apps/remix/app/components/tables/admin-dashboard-users-table.tsx
msgid "Subscription"
@@ -5191,7 +5191,7 @@ msgstr "Teamname"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
msgid "Team not found"
msgstr ""
msgstr "Team nicht gefunden"
#: apps/remix/app/components/tables/templates-table.tsx
msgid "Team Only"
@@ -5414,7 +5414,7 @@ msgstr "Der Name des Dokuments"
#: apps/remix/app/components/forms/signin.tsx
msgid "The email or password provided is incorrect"
msgstr ""
msgstr "Die angegebene E-Mail oder das Passwort ist falsch"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
@@ -5464,7 +5464,7 @@ msgstr "Der angegebene Grund für die Löschung ist folgender:"
#: packages/ui/components/recipient/recipient-role-select.tsx
msgid "The recipient can prepare the document for later signers by pre-filling suggest values."
msgstr ""
msgstr "Der Empfänger kann das Dokument für spätere Unterzeichner vorbereiten, indem er vorgeschlagene Werte vorab ausfüllt."
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
msgid "The recipient has been updated successfully"
@@ -5531,10 +5531,9 @@ msgid "The team transfer request to <0>{0}</0> has expired."
msgstr "Die Teamübertragungsanfrage an <0>{0}</0> ist abgelaufen."
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
msgid ""
"The team you are looking for may have been removed, renamed or may have never\n"
msgid "The team you are looking for may have been removed, renamed or may have never\n"
" existed."
msgstr ""
msgstr "Das Team, das Sie suchen, könnte entfernt, umbenannt oder nie existiert haben."
#: apps/remix/app/components/dialogs/template-move-dialog.tsx
msgid "The template has been successfully moved to the selected team."
@@ -5558,11 +5557,11 @@ msgstr "Der Token, den Sie zur Zurücksetzung Ihres Passworts verwendet haben, i
#: apps/remix/app/components/forms/signin.tsx
msgid "The two-factor authentication code provided is incorrect"
msgstr ""
msgstr "Der bereitgestellte Code der Zwei-Faktor-Authentifizierung ist falsch"
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "The types of signatures that recipients are allowed to use when signing the document."
msgstr ""
msgstr "Die Signaturtypen, die Empfänger beim Unterschreiben des Dokuments verwenden dürfen."
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx
#: apps/remix/app/routes/_authenticated+/settings+/webhooks.$id.tsx
@@ -5597,11 +5596,11 @@ msgstr "Sie haben in Ihrem Namen die Erlaubnis, zu:"
#: apps/remix/app/components/forms/signin.tsx
msgid "This account has been disabled. Please contact support."
msgstr ""
msgstr "Dieses Konto wurde deaktiviert. Bitte kontaktieren Sie den Support."
#: apps/remix/app/components/forms/signin.tsx
msgid "This account has not been verified. Please verify your account before signing in."
msgstr ""
msgstr "Dieses Konto wurde nicht verifiziert. Bitte verifizieren Sie Ihr Konto, bevor Sie sich anmelden."
#: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx
@@ -5647,7 +5646,7 @@ msgstr "Dieses Dokument wurde vom Eigentümer storniert."
#: apps/remix/app/routes/_authenticated+/documents.$id._index.tsx
msgid "This document has been rejected by a recipient"
msgstr ""
msgstr "Dieses Dokument wurde von einem Empfänger abgelehnt"
#: apps/remix/app/routes/_authenticated+/documents.$id._index.tsx
msgid "This document has been signed by all recipients"
@@ -5799,7 +5798,7 @@ msgstr "Titel"
#: packages/ui/primitives/document-flow/add-settings.types.ts
msgid "Title cannot be empty"
msgstr ""
msgstr "Titel darf nicht leer sein"
#: apps/remix/app/routes/_unauthenticated+/team.invite.$token.tsx
msgid "To accept this invitation you must create an account."
@@ -6164,7 +6163,7 @@ msgstr "Upgrade"
#: packages/lib/constants/document.ts
msgid "Upload"
msgstr ""
msgstr "Hochladen"
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Upload a CSV file to create multiple documents from this template. Each row represents one document with its recipient details."
@@ -6453,7 +6452,7 @@ msgstr "Möchten Sie Ihr eigenes öffentliches Profil haben?"
#: packages/ui/primitives/document-flow/add-signers.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "Warning: Assistant as last signer"
msgstr ""
msgstr "Warnung: Assistent als letzter Unterzeichner"
#: apps/remix/app/components/general/billing-portal-button.tsx
#: apps/remix/app/components/general/teams/team-layout-billing-banner.tsx
@@ -6648,7 +6647,7 @@ msgstr "Wir konnten Ihre Angaben nicht verifizieren. Bitte versuchen Sie es erne
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "We were unable to verify your email at this time."
msgstr ""
msgstr "Wir konnten Ihre E-Mail derzeit nicht verifizieren."
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "We were unable to verify your email. If your email is not verified already, please try again."
@@ -6713,7 +6712,7 @@ msgstr "Webhooks"
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Weekly"
msgstr ""
msgstr "Wöchentlich"
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Welcome"
@@ -6734,7 +6733,7 @@ msgstr "Hast du stattdessen versucht, dieses Dokument zu bearbeiten?"
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr ""
msgstr "Wenn aktiviert, können Unterzeichner auswählen, wer als nächster in der Reihenfolge unterzeichnen soll, anstatt der vorgegebenen Reihenfolge zu folgen."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."
@@ -6811,7 +6810,7 @@ msgstr "Sie sind dabei, dieses Dokument an die Empfänger zu senden. Sind Sie si
#: apps/remix/app/routes/_authenticated+/settings+/billing.tsx
msgid "You are currently on the <0>Free Plan</0>."
msgstr ""
msgstr "Sie befinden sich derzeit im <0>kostenlosen Plan</0>."
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You are currently updating <0>{teamMemberName}.</0>"
@@ -6880,7 +6879,7 @@ msgstr "Sie können das Dokument und seinen Status einsehen, indem Sie auf die S
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "You cannot add assistants when signing order is disabled."
msgstr ""
msgstr "Sie können keine Assistenten hinzufügen, wenn die Unterschriftenreihenfolge deaktiviert ist."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "You cannot have more than {MAXIMUM_PASSKEYS} passkeys."
@@ -6900,7 +6899,7 @@ msgstr "Sie können keine verschlüsselten PDFs hochladen"
#: apps/remix/app/components/general/billing-portal-button.tsx
msgid "You do not currently have a customer record, this should not happen. Please contact support for assistance."
msgstr ""
msgstr "Sie haben derzeit keinen Kundenrecord, das sollte nicht passieren. Bitte kontaktieren Sie den Support um Hilfe."
#: apps/remix/app/components/forms/token.tsx
msgid "You do not have permission to create a token for this team"
@@ -7055,7 +7054,7 @@ msgstr "Sie müssen eine Profil-URL festlegen, bevor Sie Ihr öffentliches Profi
#: apps/remix/app/routes/_authenticated+/settings+/tokens.tsx
msgid "You need to be an admin to manage API tokens."
msgstr ""
msgstr "Sie müssen Administrator sein, um API-Token zu verwalten."
#: apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx
msgid "You need to be logged in as <0>{email}</0> to view this page."
@@ -7111,7 +7110,7 @@ msgstr "Ihre Massenversandoperation für Vorlage \"{templateName}\" ist abgeschl
#: apps/remix/app/routes/_authenticated+/settings+/billing.tsx
msgid "Your current plan is past due. Please update your payment information."
msgstr ""
msgstr "Ihr aktueller Plan ist überfällig. Bitte aktualisieren Sie Ihre Zahlungsinformationen."
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
msgid "Your direct signing templates"
@@ -7159,7 +7158,7 @@ msgstr "Ihre Dokumente"
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "Your email has already been confirmed. You can now use all features of Documenso."
msgstr ""
msgstr "Ihre E-Mail wurde bereits bestätigt. Sie können jetzt alle Funktionen von Documenso nutzen."
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "Your email has been successfully confirmed! You can now use all features of Documenso."
@@ -7262,3 +7261,4 @@ msgstr "Ihr Token wurde erfolgreich erstellt! Stellen Sie sicher, dass Sie es ko
#: apps/remix/app/routes/_authenticated+/settings+/tokens.tsx
msgid "Your tokens will be shown here once you create them."
msgstr "Ihre Tokens werden hier angezeigt, sobald Sie sie erstellt haben."
+90 -89
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: es\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-01-30 06:04\n"
"PO-Revision-Date: 2025-03-25 12:05\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -20,11 +20,11 @@ msgstr ""
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid " Enable direct link signing"
msgstr ""
msgstr " Habilitar la firma mediante enlace directo"
#: apps/remix/app/routes/_authenticated+/settings+/webhooks.$id.tsx
msgid " The events that will trigger a webhook to be sent to your URL."
msgstr ""
msgstr " Los eventos que activarán un webhook para ser enviado a tu URL."
#. placeholder {0}: team.name
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
@@ -35,7 +35,7 @@ msgstr "\"{0}\" te ha invitado a firmar \"ejemplo de documento\"."
#. placeholder {1}: timezone || ''
#: apps/remix/app/components/general/document-signing/document-signing-date-field.tsx
msgid "\"{0}\" will appear on the document as it has a timezone of \"{1}\"."
msgstr ""
msgstr "\"{0}\" aparecerá en el documento ya que tiene una zona horaria de \"{1}\"."
#: packages/email/template-components/template-document-super-delete.tsx
msgid "\"{documentName}\" has been deleted by an admin."
@@ -61,7 +61,7 @@ msgstr "\"{placeholderEmail}\" en nombre de \"{0}\" te ha invitado a firmar \"do
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "(You)"
msgstr ""
msgstr "(Tú)"
#. placeholder {0}: Math.abs(charactersRemaining)
#: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
@@ -206,7 +206,7 @@ msgstr "{inviterName} te ha eliminado del documento<0/>\"{documentName}\""
#. placeholder {1}: document.title
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
msgid "{inviterName} on behalf of \"{0}\" has invited you to {recipientActionVerb} the document \"{1}\"."
msgstr ""
msgstr "{inviterName} en nombre de \"{0}\" te ha invitado a {recipientActionVerb} el documento \"{1}\"."
#. placeholder {0}: _(actionVerb).toLowerCase()
#: packages/email/template-components/template-document-invite.tsx
@@ -255,7 +255,7 @@ msgstr "{prefix} abrió el documento"
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} prefilled a field"
msgstr ""
msgstr "{prefix} prefijó un campo"
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} removed a field"
@@ -421,7 +421,7 @@ msgstr "<0>Haga clic para subir</0> o arrastre y suelte"
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "<0>Drawn</0> - A signature that is drawn using a mouse or stylus."
msgstr ""
msgstr "<0>Dibujado</0> - Una firma que se dibuja usando un ratón o un lápiz óptico."
#: packages/ui/primitives/template-flow/add-template-settings.tsx
msgid "<0>Email</0> - The recipient will be emailed the document to sign, approve, etc."
@@ -471,11 +471,11 @@ msgstr "<0>Remitente:</0> Todos"
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "<0>Typed</0> - A signature that is typed using a keyboard."
msgstr ""
msgstr "<0>Escrito</0> - Una firma que se escribe usando un teclado."
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "<0>Uploaded</0> - A signature that is uploaded from a file."
msgstr ""
msgstr "<0>Cargado</0> - Una firma que se carga desde un archivo."
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
msgid "<0>You are about to complete approving <1>\"{documentTitle}\"</1>.</0><2/> Are you sure?"
@@ -504,7 +504,7 @@ msgstr "3 meses"
#: apps/remix/app/components/general/generic-error-layout.tsx
msgid "404 not found"
msgstr ""
msgstr "404 no encontrado"
#: apps/remix/app/routes/_profile+/_layout.tsx
msgid "404 Profile not found"
@@ -516,7 +516,7 @@ msgstr "404 Equipo no encontrado"
#: apps/remix/app/components/general/generic-error-layout.tsx
msgid "500 Internal Server Error"
msgstr ""
msgstr "500 Error Interno del Servidor"
#: apps/remix/app/components/forms/token.tsx
msgid "6 months"
@@ -913,12 +913,12 @@ msgstr "Permitir que los destinatarios del documento respondan directamente a es
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "Allow signers to dictate next signer"
msgstr ""
msgstr "Permitir a los firmantes dictar al siguiente firmante"
#: packages/ui/primitives/template-flow/add-template-settings.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Allowed Signature Types"
msgstr ""
msgstr "Tipos de Firmas Permitidas"
#: apps/remix/app/routes/_authenticated+/settings+/security._index.tsx
msgid "Allows authenticating using biometrics, password managers, hardware keys, etc."
@@ -1046,7 +1046,7 @@ msgstr "Ocurrió un error mientras se eliminaba el campo."
#: apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx
msgid "An error occurred while removing the selection."
msgstr ""
msgstr "Ocurrió un error al eliminar la selección."
#: apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx
msgid "An error occurred while removing the signature."
@@ -1070,7 +1070,7 @@ msgstr "Ocurrió un error al enviar tu correo electrónico de confirmación"
#: apps/remix/app/components/general/document-signing/document-signing-date-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx
msgid "An error occurred while signing as assistant."
msgstr ""
msgstr "Ocurrió un error al firmar como asistente."
#: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx
@@ -1087,7 +1087,7 @@ msgstr "Ocurrió un error al firmar el documento."
#: apps/remix/app/components/general/billing-plans.tsx
msgid "An error occurred while trying to create a checkout session."
msgstr ""
msgstr "Ocurrió un error al intentar crear una sesión de pago."
#: apps/remix/app/components/general/template/template-edit-form.tsx
#: apps/remix/app/components/general/document/document-edit-form.tsx
@@ -1108,7 +1108,7 @@ msgstr "Ocurrió un error al subir tu documento."
#: apps/remix/app/components/general/generic-error-layout.tsx
msgid "An unexpected error occurred."
msgstr ""
msgstr "Ocurrió un error inesperado."
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
@@ -1197,7 +1197,7 @@ msgstr "Aprobando"
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Are you sure you want to complete the document? This action cannot be undone. Please ensure that you have completed prefilling all relevant fields before proceeding."
msgstr ""
msgstr "¿Estás seguro de que quieres completar el documento? Esta acción no se puede deshacer. Por favor, asegúrate de haber completado todos los campos relevantes antes de continuar."
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
msgid "Are you sure you want to delete this token?"
@@ -1227,44 +1227,44 @@ msgstr "¿Estás seguro?"
#: packages/lib/constants/recipient-roles.ts
msgid "Assist"
msgstr ""
msgstr "Asistir"
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: packages/email/template-components/template-document-invite.tsx
msgid "Assist Document"
msgstr ""
msgstr "Asistir Documento"
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "Assist with signing"
msgstr ""
msgstr "Asistir con la firma"
#: packages/lib/constants/recipient-roles.ts
msgid "Assistant"
msgstr ""
msgstr "Asistente"
#: packages/ui/components/recipient/recipient-role-select.tsx
msgid "Assistant role is only available when the document is in sequential signing mode."
msgstr ""
msgstr "El rol de asistente solo está disponible cuando el documento está en modo de firma secuencial."
#: packages/lib/constants/recipient-roles.ts
msgid "Assistants"
msgstr ""
msgstr "Asistentes"
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: packages/lib/constants/recipient-roles.ts
msgid "Assisted"
msgstr ""
msgstr "Asistido"
#: packages/lib/constants/recipient-roles.ts
msgid "Assisting"
msgstr ""
msgstr "Asistiendo"
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
#: packages/ui/primitives/document-flow/add-settings.types.ts
#: packages/lib/types/document-meta.ts
msgid "At least one signature type must be enabled"
msgstr ""
msgstr "Al menos un tipo de firma debe estar habilitado"
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
msgid "Attempts sealing the document again, useful for after a code change has occurred to resolve an erroneous document."
@@ -1403,7 +1403,7 @@ msgstr "Al eliminar este documento, ocurrirá lo siguiente:"
#: apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx
msgid "By enabling 2FA, you will be required to enter a code from your authenticator app every time you sign in using email password."
msgstr ""
msgstr "Al habilitar 2FA, se te requerirá ingresar un código de tu aplicación de autenticación cada vez que inicies sesión usando email y contraseña."
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "By proceeding to use the electronic signature service provided by Documenso, you affirm that you have read and understood this disclosure. You agree to all terms and conditions related to the use of electronic signatures and electronic transactions as outlined herein."
@@ -1415,7 +1415,7 @@ msgstr "Al continuar con su firma electrónica, usted reconoce y consiente que s
#: apps/remix/app/components/forms/signup.tsx
msgid "By proceeding, you agree to our <0>Terms of Service</0> and <1>Privacy Policy</1>."
msgstr ""
msgstr "Al proceder, aceptas nuestros <0>Términos de Servicio</0> y <1>Política de Privacidad</1>."
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "By using the electronic signature feature, you are consenting to conduct transactions and receive disclosures electronically. You acknowledge that your electronic signature on documents is binding and that you accept the terms outlined in the documents you are signing."
@@ -1423,7 +1423,7 @@ msgstr "Al utilizar la función de firma electrónica, usted está consintiendo
#: packages/ui/components/recipient/recipient-role-select.tsx
msgid "Can prepare"
msgstr ""
msgstr "Puede preparar"
#: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
#: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
@@ -1616,11 +1616,11 @@ msgstr "Completar Aprobación"
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
msgid "Complete Assisting"
msgstr ""
msgstr "Completar Asistencia"
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Complete Document"
msgstr ""
msgstr "Documento Completo"
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
msgid "Complete Signing"
@@ -1628,7 +1628,7 @@ msgstr "Completar Firmado"
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
msgid "Complete the fields for the following signers. Once reviewed, they will inform you if any modifications are needed."
msgstr ""
msgstr "Completa los campos para los siguientes firmantes. Una vez revisados, te informarán si se necesitan modificaciones."
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
@@ -1743,7 +1743,7 @@ msgstr "Continúa aprobando el documento."
#: packages/email/template-components/template-document-invite.tsx
msgid "Continue by assisting with the document."
msgstr ""
msgstr "Continúa asistiendo con el documento."
#: packages/email/template-components/template-document-completed.tsx
msgid "Continue by downloading the document."
@@ -1775,7 +1775,7 @@ msgstr "Controla el formato del mensaje que se enviará al invitar a un destinat
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Controls the language for the document, including the language to be used for email notifications, and the final certificate that is generated and attached to the document."
msgstr ""
msgstr "Controla el idioma para el documento, incluyendo el idioma a utilizar para las notificaciones de correo electrónico y el certificado final que se genera y adjunta al documento."
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
msgid "Controls whether the signing certificate will be included in the document when it is downloaded. The signing certificate can still be downloaded from the logs page separately."
@@ -1783,7 +1783,7 @@ msgstr "Controla si el certificado de firma se incluirá en el documento cuando
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
msgid "Controls which signatures are allowed to be used when signing a document."
msgstr ""
msgstr "Controla qué firmas están permitidas al firmar un documento."
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
@@ -1975,7 +1975,7 @@ msgstr "Destinatarios actuales:"
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Daily"
msgstr ""
msgstr "Diario"
#: apps/remix/app/components/general/app-command-menu.tsx
msgid "Dark Mode"
@@ -2017,7 +2017,7 @@ msgstr "Visibilidad predeterminada del documento"
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
msgid "Default Signature Settings"
msgstr ""
msgstr "Configuraciones de Firma por Defecto"
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
msgid "delete"
@@ -2248,7 +2248,7 @@ msgstr "Documento \"{0}\" - Rechazo confirmado"
#. placeholder {0}: document.title
#: packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts
msgid "Document \"{0}\" Cancelled"
msgstr ""
msgstr "Documento \"{0}\" Cancelado"
#: packages/ui/primitives/template-flow/add-template-settings.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
@@ -2400,7 +2400,7 @@ msgstr "Documento reenviado"
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
msgstr ""
msgstr "Documento rechazado"
#: apps/remix/app/routes/_recipient+/sign.$token+/rejected.tsx
#: apps/remix/app/components/embed/embed-document-rejected.tsx
@@ -2541,7 +2541,7 @@ msgstr "Arrastre y suelte su PDF aquí."
#: packages/lib/constants/document.ts
msgid "Draw"
msgstr ""
msgstr "Dibujar"
#: packages/ui/primitives/template-flow/add-template-fields.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
@@ -2635,7 +2635,7 @@ msgstr "Dirección de correo electrónico"
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "Email already confirmed"
msgstr ""
msgstr "Correo electrónico ya confirmado"
#: apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx
msgid "Email cannot already exist in the template"
@@ -2873,7 +2873,7 @@ msgstr "Marcador de posición de campo"
#: packages/lib/utils/document-audit-logs.ts
msgid "Field prefilled by assistant"
msgstr ""
msgstr "Campo rellenado por el asistente"
#: packages/lib/utils/document-audit-logs.ts
msgid "Field signed"
@@ -2989,7 +2989,7 @@ msgstr "te ha invitado a aprobar este documento"
#: apps/remix/app/components/general/document-signing/document-signing-page-view.tsx
msgid "has invited you to assist this document"
msgstr ""
msgstr "te ha invitado a asistir con este documento"
#: apps/remix/app/components/general/document-signing/document-signing-page-view.tsx
msgid "has invited you to sign this document"
@@ -3006,11 +3006,11 @@ msgstr "te ha invitado a ver este documento"
#: packages/ui/primitives/document-flow/add-signers.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist."
msgstr ""
msgstr "Tener a un asistente como el último firmante significa que no podrá realizar ninguna acción ya que no hay firmantes posteriores a los que asistir."
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "Help complete the document for other signers."
msgstr ""
msgstr "Ayuda a completar el documento para otros firmantes."
#: apps/remix/app/routes/_authenticated+/settings+/profile.tsx
msgid "Here you can edit your personal details."
@@ -3068,7 +3068,7 @@ msgstr "Soy un aprobador de este documento"
#: packages/lib/constants/recipient-roles.ts
msgid "I am an assistant of this document"
msgstr ""
msgstr "Soy asistente de este documento"
#: packages/lib/constants/recipient-roles.ts
msgid "I am required to receive a copy of this document"
@@ -3220,7 +3220,7 @@ msgstr "Parece que no se ha proporcionado un token, si estás intentando verific
#: apps/remix/app/components/embed/embed-document-waiting-for-turn.tsx
msgid "It's currently not your turn to sign. Please check back soon as this document should be available for you to sign shortly."
msgstr ""
msgstr "Actualmente no es tu turno para firmar. Por favor, vuelve pronto ya que este documento debería estar disponible para que firmes en breve."
#: apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx
msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document."
@@ -3369,7 +3369,7 @@ msgstr "Gestionar y ver plantilla"
#: apps/remix/app/routes/_authenticated+/settings+/billing.tsx
msgid "Manage billing"
msgstr ""
msgstr "Gestionar la facturación"
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
msgid "Manage details for this public template"
@@ -3393,7 +3393,7 @@ msgstr "Gestionar suscripción"
#: apps/remix/app/components/general/billing-portal-button.tsx
msgid "Manage Subscription"
msgstr ""
msgstr "Gestionar Suscripción"
#: apps/remix/app/routes/_authenticated+/admin+/subscriptions.tsx
msgid "Manage subscriptions"
@@ -3433,7 +3433,7 @@ msgstr "Gerente"
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
msgid "Mark as viewed"
msgstr ""
msgstr "Marcar como visto"
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
@@ -3575,7 +3575,7 @@ msgstr "Nunca expira"
#: apps/remix/app/components/forms/password.tsx
msgid "New Password"
msgstr ""
msgstr "Nueva Contraseña"
#: apps/remix/app/components/dialogs/team-transfer-dialog.tsx
msgid "New team owner"
@@ -3709,7 +3709,7 @@ msgstr "en nombre de \"{0}\" te ha invitado a aprobar este documento"
#. placeholder {0}: document.team?.name
#: apps/remix/app/components/general/document-signing/document-signing-page-view.tsx
msgid "on behalf of \"{0}\" has invited you to assist this document"
msgstr ""
msgstr "en nombre de \"{0}\" te ha invitado a asistir con este documento"
#. placeholder {0}: document.team?.name
#: apps/remix/app/components/general/document-signing/document-signing-page-view.tsx
@@ -3727,7 +3727,7 @@ msgstr "En esta página, puedes crear un nuevo webhook."
#: apps/remix/app/routes/_authenticated+/settings+/tokens.tsx
msgid "On this page, you can create and manage API tokens. See our <0>Documentation</0> for more information."
msgstr ""
msgstr "En esta página, puedes crear y gestionar tokens de API. Consulta nuestra <0>Documentación</0> para más información."
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
@@ -3979,7 +3979,7 @@ msgstr "Por favor, revisa el archivo CSV y asegúrate de que esté de acuerdo co
#: apps/remix/app/components/embed/embed-document-waiting-for-turn.tsx
msgid "Please check with the parent application for more information."
msgstr ""
msgstr "Por favor consulta con la aplicación principal para obtener más información."
#: apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx
msgid "Please check your email for updates."
@@ -4053,7 +4053,7 @@ msgstr "Por favor, proporciona un token de tu autenticador, o un código de resp
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
msgid "Please review the document before approving."
msgstr ""
msgstr "Por favor, revise el documento antes de aprobarlo."
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
msgid "Please review the document before signing."
@@ -4115,7 +4115,7 @@ msgstr "Las plantillas privadas solo pueden ser modificadas y vistas por ti."
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Proceed"
msgstr ""
msgstr "Proceder"
#: apps/remix/app/routes/_authenticated+/settings+/profile.tsx
#: apps/remix/app/components/general/settings-nav-mobile.tsx
@@ -4196,11 +4196,11 @@ msgstr "Razón"
#: packages/email/template-components/template-document-cancel.tsx
msgid "Reason for cancellation: {cancellationReason}"
msgstr ""
msgstr "Razón de cancelación: {cancellationReason}"
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Reason for rejection: "
msgstr ""
msgstr "Razón del rechazo: "
#: packages/email/template-components/template-document-rejected.tsx
msgid "Reason for rejection: {rejectionReason}"
@@ -4817,15 +4817,15 @@ msgstr "ID de Firma"
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
msgid "Signature is too small"
msgstr ""
msgstr "La firma es demasiado pequeña"
#: apps/remix/app/components/forms/profile.tsx
msgid "Signature Pad cannot be empty."
msgstr ""
msgstr "El área de firma no puede estar vacío."
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "Signature types"
msgstr ""
msgstr "Tipos de firma"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Signatures Collected"
@@ -4877,7 +4877,7 @@ msgstr "¡Firma completa!"
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "Signing for"
msgstr ""
msgstr "Firmando para"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signin.tsx
@@ -4896,7 +4896,7 @@ msgstr "Se han generado enlaces de firma para este documento."
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "Signing order is enabled."
msgstr ""
msgstr "El orden de firma está habilitado."
#: apps/remix/app/routes/_authenticated+/admin+/leaderboard.tsx
#: apps/remix/app/components/tables/admin-leaderboard-table.tsx
@@ -5051,7 +5051,7 @@ msgstr "Asunto <0>(Opcional)</0>"
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Subscribe"
msgstr ""
msgstr "Suscribirse"
#: apps/remix/app/components/tables/admin-dashboard-users-table.tsx
msgid "Subscription"
@@ -5191,7 +5191,7 @@ msgstr "Nombre del equipo"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
msgid "Team not found"
msgstr ""
msgstr "Equipo no encontrado"
#: apps/remix/app/components/tables/templates-table.tsx
msgid "Team Only"
@@ -5414,7 +5414,7 @@ msgstr "El nombre del documento"
#: apps/remix/app/components/forms/signin.tsx
msgid "The email or password provided is incorrect"
msgstr ""
msgstr "El correo electrónico o la contraseña proporcionada es incorrecta"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
@@ -5464,7 +5464,7 @@ msgstr "La razón proporcionada para la eliminación es la siguiente:"
#: packages/ui/components/recipient/recipient-role-select.tsx
msgid "The recipient can prepare the document for later signers by pre-filling suggest values."
msgstr ""
msgstr "El destinatario puede preparar el documento para firmantes posteriores rellenando valores sugeridos."
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
msgid "The recipient has been updated successfully"
@@ -5531,10 +5531,10 @@ msgid "The team transfer request to <0>{0}</0> has expired."
msgstr "La solicitud de transferencia de equipo a <0>{0}</0> ha expirado."
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
msgid ""
"The team you are looking for may have been removed, renamed or may have never\n"
msgid "The team you are looking for may have been removed, renamed or may have never\n"
" existed."
msgstr ""
msgstr "El equipo que buscas puede haber sido eliminado, renombrado o quizás nunca\n"
" existió."
#: apps/remix/app/components/dialogs/template-move-dialog.tsx
msgid "The template has been successfully moved to the selected team."
@@ -5558,11 +5558,11 @@ msgstr "El token que has utilizado para restablecer tu contraseña ha expirado o
#: apps/remix/app/components/forms/signin.tsx
msgid "The two-factor authentication code provided is incorrect"
msgstr ""
msgstr "El código de autenticación de dos factores proporcionado es incorrecto"
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "The types of signatures that recipients are allowed to use when signing the document."
msgstr ""
msgstr "Los tipos de firmas que los destinatarios pueden usar al firmar el documento."
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx
#: apps/remix/app/routes/_authenticated+/settings+/webhooks.$id.tsx
@@ -5597,11 +5597,11 @@ msgstr "Tienen permiso en tu nombre para:"
#: apps/remix/app/components/forms/signin.tsx
msgid "This account has been disabled. Please contact support."
msgstr ""
msgstr "Esta cuenta ha sido deshabilitada. Por favor, contacta con soporte."
#: apps/remix/app/components/forms/signin.tsx
msgid "This account has not been verified. Please verify your account before signing in."
msgstr ""
msgstr "Esta cuenta no ha sido verificada. Por favor, verifica tu cuenta antes de iniciar sesión."
#: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx
@@ -5647,7 +5647,7 @@ msgstr "Este documento ha sido cancelado por el propietario."
#: apps/remix/app/routes/_authenticated+/documents.$id._index.tsx
msgid "This document has been rejected by a recipient"
msgstr ""
msgstr "Este documento ha sido rechazado por un destinatario"
#: apps/remix/app/routes/_authenticated+/documents.$id._index.tsx
msgid "This document has been signed by all recipients"
@@ -5799,7 +5799,7 @@ msgstr "Título"
#: packages/ui/primitives/document-flow/add-settings.types.ts
msgid "Title cannot be empty"
msgstr ""
msgstr "El título no puede estar vacío"
#: apps/remix/app/routes/_unauthenticated+/team.invite.$token.tsx
msgid "To accept this invitation you must create an account."
@@ -6164,7 +6164,7 @@ msgstr "Actualizar"
#: packages/lib/constants/document.ts
msgid "Upload"
msgstr ""
msgstr "Subir"
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Upload a CSV file to create multiple documents from this template. Each row represents one document with its recipient details."
@@ -6453,7 +6453,7 @@ msgstr "¿Quieres tu propio perfil público?"
#: packages/ui/primitives/document-flow/add-signers.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "Warning: Assistant as last signer"
msgstr ""
msgstr "Advertencia: Asistente como último firmante"
#: apps/remix/app/components/general/billing-portal-button.tsx
#: apps/remix/app/components/general/teams/team-layout-billing-banner.tsx
@@ -6648,7 +6648,7 @@ msgstr "No pudimos verificar tus datos. Por favor, inténtalo de nuevo o contact
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "We were unable to verify your email at this time."
msgstr ""
msgstr "No pudimos verificar tu correo electrónico en este momento."
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "We were unable to verify your email. If your email is not verified already, please try again."
@@ -6713,7 +6713,7 @@ msgstr "Webhooks"
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Weekly"
msgstr ""
msgstr "Semanal"
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Welcome"
@@ -6734,7 +6734,7 @@ msgstr "¿Estabas intentando editar este documento en su lugar?"
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr ""
msgstr "Cuando está habilitado, los firmantes pueden elegir quién debe firmar a continuación en la secuencia en lugar de seguir el orden predefinido."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."
@@ -6811,7 +6811,7 @@ msgstr "Está a punto de enviar este documento a los destinatarios. ¿Está segu
#: apps/remix/app/routes/_authenticated+/settings+/billing.tsx
msgid "You are currently on the <0>Free Plan</0>."
msgstr ""
msgstr "Actualmente estás en el <0>Plan Gratuito</0>."
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You are currently updating <0>{teamMemberName}.</0>"
@@ -6880,7 +6880,7 @@ msgstr "Puede ver el documento y su estado haciendo clic en el botón de abajo."
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "You cannot add assistants when signing order is disabled."
msgstr ""
msgstr "No puedes añadir asistentes cuando el orden de firma está deshabilitado."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "You cannot have more than {MAXIMUM_PASSKEYS} passkeys."
@@ -6900,7 +6900,7 @@ msgstr "No puedes subir PDFs encriptados"
#: apps/remix/app/components/general/billing-portal-button.tsx
msgid "You do not currently have a customer record, this should not happen. Please contact support for assistance."
msgstr ""
msgstr "Actualmente no tienes un registro de cliente, esto no debería suceder. Por favor contacta a soporte para obtener asistencia."
#: apps/remix/app/components/forms/token.tsx
msgid "You do not have permission to create a token for this team"
@@ -7055,7 +7055,7 @@ msgstr "Debes establecer una URL de perfil antes de habilitar tu perfil público
#: apps/remix/app/routes/_authenticated+/settings+/tokens.tsx
msgid "You need to be an admin to manage API tokens."
msgstr ""
msgstr "Necesitas ser administrador para gestionar tokens de API."
#: apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx
msgid "You need to be logged in as <0>{email}</0> to view this page."
@@ -7111,7 +7111,7 @@ msgstr "Tu operación de envío masivo para la plantilla \"{templateName}\" ha s
#: apps/remix/app/routes/_authenticated+/settings+/billing.tsx
msgid "Your current plan is past due. Please update your payment information."
msgstr ""
msgstr "Tu plan actual está vencido. Por favor actualiza tu información de pago."
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
msgid "Your direct signing templates"
@@ -7159,7 +7159,7 @@ msgstr "Tus documentos"
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "Your email has already been confirmed. You can now use all features of Documenso."
msgstr ""
msgstr "Tu correo electrónico ya ha sido confirmado. Ahora puedes usar todas las funciones de Documenso."
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "Your email has been successfully confirmed! You can now use all features of Documenso."
@@ -7262,3 +7262,4 @@ msgstr "¡Tu token se creó con éxito! ¡Asegúrate de copiarlo porque no podr
#: apps/remix/app/routes/_authenticated+/settings+/tokens.tsx
msgid "Your tokens will be shown here once you create them."
msgstr "Tus tokens se mostrarán aquí una vez que los crees."
+92 -92
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: fr\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-01-30 06:04\n"
"PO-Revision-Date: 2025-03-25 12:05\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
@@ -20,11 +20,11 @@ msgstr ""
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid " Enable direct link signing"
msgstr ""
msgstr " Activer la signature de lien direct"
#: apps/remix/app/routes/_authenticated+/settings+/webhooks.$id.tsx
msgid " The events that will trigger a webhook to be sent to your URL."
msgstr ""
msgstr " Les événements qui déclencheront l'envoi d'un webhook vers votre URL."
#. placeholder {0}: team.name
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
@@ -35,7 +35,7 @@ msgstr "\"{0}\" vous a invité à signer \"example document\"."
#. placeholder {1}: timezone || ''
#: apps/remix/app/components/general/document-signing/document-signing-date-field.tsx
msgid "\"{0}\" will appear on the document as it has a timezone of \"{1}\"."
msgstr ""
msgstr "\"{0}\" apparaîtra sur le document car il a un fuseau horaire de \"{1}\"."
#: packages/email/template-components/template-document-super-delete.tsx
msgid "\"{documentName}\" has been deleted by an admin."
@@ -61,7 +61,7 @@ msgstr "\"{placeholderEmail}\" au nom de \"{0}\" vous a invité à signer \"exem
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "(You)"
msgstr ""
msgstr "(Vous)"
#. placeholder {0}: Math.abs(charactersRemaining)
#: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
@@ -206,7 +206,7 @@ msgstr "{inviterName} vous a retiré du document<0/>\"{documentName}\""
#. placeholder {1}: document.title
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
msgid "{inviterName} on behalf of \"{0}\" has invited you to {recipientActionVerb} the document \"{1}\"."
msgstr ""
msgstr "{inviterName} représentant \"{0}\" vous a invité à {recipientActionVerb} le document \"{1}\"."
#. placeholder {0}: _(actionVerb).toLowerCase()
#: packages/email/template-components/template-document-invite.tsx
@@ -255,7 +255,7 @@ msgstr "{prefix} a ouvert le document"
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} prefilled a field"
msgstr ""
msgstr "{prefix} a pré-rempli un champ"
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} removed a field"
@@ -421,7 +421,7 @@ msgstr "<0>Cliquez pour importer</0> ou faites glisser et déposez"
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "<0>Drawn</0> - A signature that is drawn using a mouse or stylus."
msgstr ""
msgstr "<0>Signée</0> - Une signature dessinée en utilisant une souris ou un stylet."
#: packages/ui/primitives/template-flow/add-template-settings.tsx
msgid "<0>Email</0> - The recipient will be emailed the document to sign, approve, etc."
@@ -471,11 +471,11 @@ msgstr "<0>Expéditeur :</0> Tous"
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "<0>Typed</0> - A signature that is typed using a keyboard."
msgstr ""
msgstr "<0>Tappée</0> - Une signature tapée à l'aide d'un clavier."
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "<0>Uploaded</0> - A signature that is uploaded from a file."
msgstr ""
msgstr "<0>Téléchargée</0> - Une signature téléchargée à partir d'un fichier."
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
msgid "<0>You are about to complete approving <1>\"{documentTitle}\"</1>.</0><2/> Are you sure?"
@@ -504,7 +504,7 @@ msgstr "3 mois"
#: apps/remix/app/components/general/generic-error-layout.tsx
msgid "404 not found"
msgstr ""
msgstr "404 non trouvé"
#: apps/remix/app/routes/_profile+/_layout.tsx
msgid "404 Profile not found"
@@ -516,7 +516,7 @@ msgstr "404 Équipe non trouvée"
#: apps/remix/app/components/general/generic-error-layout.tsx
msgid "500 Internal Server Error"
msgstr ""
msgstr "500 Erreur Interne du Serveur"
#: apps/remix/app/components/forms/token.tsx
msgid "6 months"
@@ -913,12 +913,12 @@ msgstr "Autoriser les destinataires du document à répondre directement à cett
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "Allow signers to dictate next signer"
msgstr ""
msgstr "Permettre aux signataires de dicter le prochain signataire"
#: packages/ui/primitives/template-flow/add-template-settings.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Allowed Signature Types"
msgstr ""
msgstr "Types de signatures autorisées"
#: apps/remix/app/routes/_authenticated+/settings+/security._index.tsx
msgid "Allows authenticating using biometrics, password managers, hardware keys, etc."
@@ -1046,7 +1046,7 @@ msgstr "Une erreur est survenue lors de la suppression du champ."
#: apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx
msgid "An error occurred while removing the selection."
msgstr ""
msgstr "Une erreur s'est produite lors de la suppression de la sélection."
#: apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx
msgid "An error occurred while removing the signature."
@@ -1070,7 +1070,7 @@ msgstr "Une erreur est survenue lors de l'envoi de votre e-mail de confirmation"
#: apps/remix/app/components/general/document-signing/document-signing-date-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx
msgid "An error occurred while signing as assistant."
msgstr ""
msgstr "Une erreur s'est produite lors de la signature en tant qu'assistant."
#: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx
@@ -1087,7 +1087,7 @@ msgstr "Une erreur est survenue lors de la signature du document."
#: apps/remix/app/components/general/billing-plans.tsx
msgid "An error occurred while trying to create a checkout session."
msgstr ""
msgstr "Une erreur est survenue lors de la création d'une session de paiement."
#: apps/remix/app/components/general/template/template-edit-form.tsx
#: apps/remix/app/components/general/document/document-edit-form.tsx
@@ -1108,7 +1108,7 @@ msgstr "Une erreur est survenue lors de l'importation de votre document."
#: apps/remix/app/components/general/generic-error-layout.tsx
msgid "An unexpected error occurred."
msgstr ""
msgstr "Une erreur inattendue est survenue."
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
@@ -1197,7 +1197,7 @@ msgstr "Approval en cours"
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Are you sure you want to complete the document? This action cannot be undone. Please ensure that you have completed prefilling all relevant fields before proceeding."
msgstr ""
msgstr "Êtes-vous sûr de vouloir terminer le document ? Cette action ne peut être annulée. Veuillez vous assurer d'avoir pré-rempli tous les champs pertinents avant de procéder."
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
msgid "Are you sure you want to delete this token?"
@@ -1227,44 +1227,44 @@ msgstr "Êtes-vous sûr ?"
#: packages/lib/constants/recipient-roles.ts
msgid "Assist"
msgstr ""
msgstr "Aider"
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: packages/email/template-components/template-document-invite.tsx
msgid "Assist Document"
msgstr ""
msgstr "Assister le Document"
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "Assist with signing"
msgstr ""
msgstr "Aider à la signature"
#: packages/lib/constants/recipient-roles.ts
msgid "Assistant"
msgstr ""
msgstr "\"\""
#: packages/ui/components/recipient/recipient-role-select.tsx
msgid "Assistant role is only available when the document is in sequential signing mode."
msgstr ""
msgstr "Le rôle d'assistant est uniquement disponible lorsque le document est en mode de signature séquentielle."
#: packages/lib/constants/recipient-roles.ts
msgid "Assistants"
msgstr ""
msgstr "\"\""
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: packages/lib/constants/recipient-roles.ts
msgid "Assisted"
msgstr ""
msgstr "Assisté"
#: packages/lib/constants/recipient-roles.ts
msgid "Assisting"
msgstr ""
msgstr "En assistance"
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
#: packages/ui/primitives/document-flow/add-settings.types.ts
#: packages/lib/types/document-meta.ts
msgid "At least one signature type must be enabled"
msgstr ""
msgstr "Au moins un type de signature doit être activé"
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
msgid "Attempts sealing the document again, useful for after a code change has occurred to resolve an erroneous document."
@@ -1403,7 +1403,7 @@ msgstr "En supprimant ce document, les éléments suivants se produiront :"
#: apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx
msgid "By enabling 2FA, you will be required to enter a code from your authenticator app every time you sign in using email password."
msgstr ""
msgstr "En activant l'authentification à deux facteurs, vous devrez entrer un code de votre application d'authentification à chaque fois que vous vous connectez avec votre mot de passe par email."
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "By proceeding to use the electronic signature service provided by Documenso, you affirm that you have read and understood this disclosure. You agree to all terms and conditions related to the use of electronic signatures and electronic transactions as outlined herein."
@@ -1415,7 +1415,7 @@ msgstr "En procédant avec votre signature électronique, vous reconnaissez et c
#: apps/remix/app/components/forms/signup.tsx
msgid "By proceeding, you agree to our <0>Terms of Service</0> and <1>Privacy Policy</1>."
msgstr ""
msgstr "En poursuivant, vous acceptez nos <0>Conditions d'Utilisation</0> et notre <1>Politique de Confidentialité</1>."
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "By using the electronic signature feature, you are consenting to conduct transactions and receive disclosures electronically. You acknowledge that your electronic signature on documents is binding and that you accept the terms outlined in the documents you are signing."
@@ -1423,7 +1423,7 @@ msgstr "En utilisant la fonctionnalité de signature électronique, vous consent
#: packages/ui/components/recipient/recipient-role-select.tsx
msgid "Can prepare"
msgstr ""
msgstr "Peut préparer"
#: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
#: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
@@ -1486,16 +1486,16 @@ msgstr "Impossible de supprimer le signataire"
#: packages/lib/constants/recipient-roles.ts
msgid "Cc"
msgstr ""
msgstr "Cc"
#: packages/lib/constants/recipient-roles.ts
#: packages/lib/constants/recipient-roles.ts
msgid "CC"
msgstr ""
msgstr "CC"
#: packages/lib/constants/recipient-roles.ts
msgid "CC'd"
msgstr ""
msgstr "CC'd"
#: packages/lib/constants/recipient-roles.ts
msgid "Ccers"
@@ -1616,11 +1616,11 @@ msgstr "Compléter l'approbation"
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
msgid "Complete Assisting"
msgstr ""
msgstr "Compléter l'Assistance"
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Complete Document"
msgstr ""
msgstr "Compléter le Document"
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
msgid "Complete Signing"
@@ -1628,7 +1628,7 @@ msgstr "Compléter la signature"
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
msgid "Complete the fields for the following signers. Once reviewed, they will inform you if any modifications are needed."
msgstr ""
msgstr "Complétez les champs pour les signataires suivants. Une fois révisés, ils vous informeront si des modifications sont nécessaires."
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
@@ -1743,7 +1743,7 @@ msgstr "Continuer en approuvant le document."
#: packages/email/template-components/template-document-invite.tsx
msgid "Continue by assisting with the document."
msgstr ""
msgstr "Continuez en aidant avec le document."
#: packages/email/template-components/template-document-completed.tsx
msgid "Continue by downloading the document."
@@ -1775,7 +1775,7 @@ msgstr "Contrôle le formatage du message qui sera envoyé lors de l'invitation
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Controls the language for the document, including the language to be used for email notifications, and the final certificate that is generated and attached to the document."
msgstr ""
msgstr "Contrôle la langue du document, y compris la langue à utiliser pour les notifications par email et le certificat final qui est généré et attaché au document."
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
msgid "Controls whether the signing certificate will be included in the document when it is downloaded. The signing certificate can still be downloaded from the logs page separately."
@@ -1783,7 +1783,7 @@ msgstr "Contrôle si le certificat de signature sera inclus dans le document lor
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
msgid "Controls which signatures are allowed to be used when signing a document."
msgstr ""
msgstr "Contrôle quelles signatures sont autorisées lors de la signature d'un document."
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
@@ -1975,7 +1975,7 @@ msgstr "Destinataires actuels :"
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Daily"
msgstr ""
msgstr "Quotidien"
#: apps/remix/app/components/general/app-command-menu.tsx
msgid "Dark Mode"
@@ -2017,7 +2017,7 @@ msgstr "Visibilité par défaut du document"
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
msgid "Default Signature Settings"
msgstr ""
msgstr "Paramètres de Signature par Défaut"
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
msgid "delete"
@@ -2248,7 +2248,7 @@ msgstr "Document \"{0}\" - Rejet Confirmé"
#. placeholder {0}: document.title
#: packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts
msgid "Document \"{0}\" Cancelled"
msgstr ""
msgstr "Document \"{0}\" Annulé"
#: packages/ui/primitives/template-flow/add-template-settings.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
@@ -2400,7 +2400,7 @@ msgstr "Document renvoyé"
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
msgstr ""
msgstr "Document rejeté"
#: apps/remix/app/routes/_recipient+/sign.$token+/rejected.tsx
#: apps/remix/app/components/embed/embed-document-rejected.tsx
@@ -2541,7 +2541,7 @@ msgstr "Faites glisser et déposez votre PDF ici."
#: packages/lib/constants/document.ts
msgid "Draw"
msgstr ""
msgstr "Dessiner"
#: packages/ui/primitives/template-flow/add-template-fields.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
@@ -2635,7 +2635,7 @@ msgstr "Adresse e-mail"
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "Email already confirmed"
msgstr ""
msgstr "E-mail déjà confirmé"
#: apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx
msgid "Email cannot already exist in the template"
@@ -2873,7 +2873,7 @@ msgstr "Espace réservé du champ"
#: packages/lib/utils/document-audit-logs.ts
msgid "Field prefilled by assistant"
msgstr ""
msgstr "Champ pré-rempli par l'assistant"
#: packages/lib/utils/document-audit-logs.ts
msgid "Field signed"
@@ -2989,7 +2989,7 @@ msgstr "t'a invité à approuver ce document"
#: apps/remix/app/components/general/document-signing/document-signing-page-view.tsx
msgid "has invited you to assist this document"
msgstr ""
msgstr "vous a invité à aider ce document"
#: apps/remix/app/components/general/document-signing/document-signing-page-view.tsx
msgid "has invited you to sign this document"
@@ -3006,11 +3006,11 @@ msgstr "t'a invité à voir ce document"
#: packages/ui/primitives/document-flow/add-signers.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist."
msgstr ""
msgstr "Avoir un assistant comme dernier signataire signifie qu'il ne pourra prendre aucune mesure car il n'y a pas de signataires ultérieurs à assister."
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "Help complete the document for other signers."
msgstr ""
msgstr "Aidez à compléter le document pour les autres signataires."
#: apps/remix/app/routes/_authenticated+/settings+/profile.tsx
msgid "Here you can edit your personal details."
@@ -3068,7 +3068,7 @@ msgstr "Je suis un approuveur de ce document"
#: packages/lib/constants/recipient-roles.ts
msgid "I am an assistant of this document"
msgstr ""
msgstr "Je suis un assistant de ce document"
#: packages/lib/constants/recipient-roles.ts
msgid "I am required to receive a copy of this document"
@@ -3220,7 +3220,7 @@ msgstr "Il semble qu'aucun token n'ait été fourni, si vous essayez de vérifie
#: apps/remix/app/components/embed/embed-document-waiting-for-turn.tsx
msgid "It's currently not your turn to sign. Please check back soon as this document should be available for you to sign shortly."
msgstr ""
msgstr "Ce n'est actuellement pas votre tour de signer. Veuillez revenir bientôt car ce document devrait être disponible pour vous sous peu."
#: apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx
msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document."
@@ -3369,7 +3369,7 @@ msgstr "Gérer et afficher le modèle"
#: apps/remix/app/routes/_authenticated+/settings+/billing.tsx
msgid "Manage billing"
msgstr ""
msgstr "Gérer la facturation"
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
msgid "Manage details for this public template"
@@ -3393,7 +3393,7 @@ msgstr "Gérer l'abonnement"
#: apps/remix/app/components/general/billing-portal-button.tsx
msgid "Manage Subscription"
msgstr ""
msgstr "Gérer l'abonnement"
#: apps/remix/app/routes/_authenticated+/admin+/subscriptions.tsx
msgid "Manage subscriptions"
@@ -3433,7 +3433,7 @@ msgstr "Gestionnaire"
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
msgid "Mark as viewed"
msgstr ""
msgstr "Marquer comme vu"
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
@@ -3478,7 +3478,7 @@ msgstr "Message <0>(Optionnel)</0>"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx
msgid "Min"
msgstr ""
msgstr "Min"
#: apps/remix/app/components/general/template/template-page-view-recipients.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
@@ -3575,7 +3575,7 @@ msgstr "Ne jamais expirer"
#: apps/remix/app/components/forms/password.tsx
msgid "New Password"
msgstr ""
msgstr "Nouveau Mot de Passe"
#: apps/remix/app/components/dialogs/team-transfer-dialog.tsx
msgid "New team owner"
@@ -3709,7 +3709,7 @@ msgstr "au nom de \"{0}\" vous a invité à approuver ce document"
#. placeholder {0}: document.team?.name
#: apps/remix/app/components/general/document-signing/document-signing-page-view.tsx
msgid "on behalf of \"{0}\" has invited you to assist this document"
msgstr ""
msgstr "au nom de \"{0}\" vous a invité à aider ce document"
#. placeholder {0}: document.team?.name
#: apps/remix/app/components/general/document-signing/document-signing-page-view.tsx
@@ -3727,7 +3727,7 @@ msgstr "Sur cette page, vous pouvez créer un nouveau webhook."
#: apps/remix/app/routes/_authenticated+/settings+/tokens.tsx
msgid "On this page, you can create and manage API tokens. See our <0>Documentation</0> for more information."
msgstr ""
msgstr "Sur cette page, vous pouvez créer et gérer des tokens API. Consultez notre <0>Documentation</0> pour plus d'informations."
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
@@ -3979,7 +3979,7 @@ msgstr "Veuillez vérifier le fichier CSV et vous assurer qu'il est conforme à
#: apps/remix/app/components/embed/embed-document-waiting-for-turn.tsx
msgid "Please check with the parent application for more information."
msgstr ""
msgstr "Veuillez vérifier auprès de l'application parent pour plus d'informations."
#: apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx
msgid "Please check your email for updates."
@@ -4053,7 +4053,7 @@ msgstr "Veuillez fournir un token de votre authentificateur, ou un code de secou
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
msgid "Please review the document before approving."
msgstr ""
msgstr "Veuillez examiner le document avant d'approuver."
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
msgid "Please review the document before signing."
@@ -4115,7 +4115,7 @@ msgstr "Les modèles privés ne peuvent être modifiés et consultés que par vo
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Proceed"
msgstr ""
msgstr "Procéder"
#: apps/remix/app/routes/_authenticated+/settings+/profile.tsx
#: apps/remix/app/components/general/settings-nav-mobile.tsx
@@ -4164,7 +4164,7 @@ msgstr "Les modèles publics sont connectés à votre profil public. Toute modif
#: packages/ui/primitives/document-flow/types.ts
msgid "Radio"
msgstr ""
msgstr "Radio"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/radio-field.tsx
msgid "Radio values"
@@ -4196,11 +4196,11 @@ msgstr "Raison"
#: packages/email/template-components/template-document-cancel.tsx
msgid "Reason for cancellation: {cancellationReason}"
msgstr ""
msgstr "Raison de l'annulation: {cancellationReason}"
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Reason for rejection: "
msgstr ""
msgstr "Raison du rejet: "
#: packages/email/template-components/template-document-rejected.tsx
msgid "Reason for rejection: {rejectionReason}"
@@ -4817,15 +4817,15 @@ msgstr "ID de signature"
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
msgid "Signature is too small"
msgstr ""
msgstr "La signature est trop petite"
#: apps/remix/app/components/forms/profile.tsx
msgid "Signature Pad cannot be empty."
msgstr ""
msgstr "Le Pad de Signature ne peut pas être vide."
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "Signature types"
msgstr ""
msgstr "Types de signature"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Signatures Collected"
@@ -4877,7 +4877,7 @@ msgstr "Signature Complète !"
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "Signing for"
msgstr ""
msgstr "Signé pour"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signin.tsx
@@ -4896,7 +4896,7 @@ msgstr "Des liens de signature ont été générés pour ce document."
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "Signing order is enabled."
msgstr ""
msgstr "L'ordre de signature est activé."
#: apps/remix/app/routes/_authenticated+/admin+/leaderboard.tsx
#: apps/remix/app/components/tables/admin-leaderboard-table.tsx
@@ -5051,7 +5051,7 @@ msgstr "Objet <0>(Optionnel)</0>"
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Subscribe"
msgstr ""
msgstr "S'abonner"
#: apps/remix/app/components/tables/admin-dashboard-users-table.tsx
msgid "Subscription"
@@ -5191,7 +5191,7 @@ msgstr "Nom de l'équipe"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
msgid "Team not found"
msgstr ""
msgstr "Équipe introuvable"
#: apps/remix/app/components/tables/templates-table.tsx
msgid "Team Only"
@@ -5414,7 +5414,7 @@ msgstr "Le nom du document"
#: apps/remix/app/components/forms/signin.tsx
msgid "The email or password provided is incorrect"
msgstr ""
msgstr "L'email ou le mot de passe fourni est incorrect"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
@@ -5464,7 +5464,7 @@ msgstr "La raison fournie pour la suppression est la suivante :"
#: packages/ui/components/recipient/recipient-role-select.tsx
msgid "The recipient can prepare the document for later signers by pre-filling suggest values."
msgstr ""
msgstr "Le destinataire peut préparer le document pour les signataires ultérieurs en remplissant à l'avance les valeurs suggérées."
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
msgid "The recipient has been updated successfully"
@@ -5531,10 +5531,9 @@ msgid "The team transfer request to <0>{0}</0> has expired."
msgstr "La demande de transfert d'équipe à <0>{0}</0> a expiré."
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
msgid ""
"The team you are looking for may have been removed, renamed or may have never\n"
msgid "The team you are looking for may have been removed, renamed or may have never\n"
" existed."
msgstr ""
msgstr "L'équipe que vous cherchez a peut-être été supprimée, renommée ou n'a peut-être jamais existé."
#: apps/remix/app/components/dialogs/template-move-dialog.tsx
msgid "The template has been successfully moved to the selected team."
@@ -5558,11 +5557,11 @@ msgstr "Le token que vous avez utilisé pour réinitialiser votre mot de passe a
#: apps/remix/app/components/forms/signin.tsx
msgid "The two-factor authentication code provided is incorrect"
msgstr ""
msgstr "Le code d'authentification à deux facteurs fourni est incorrect"
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "The types of signatures that recipients are allowed to use when signing the document."
msgstr ""
msgstr "Les types de signatures que les destinataires peuvent utiliser lors de la signature du document."
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx
#: apps/remix/app/routes/_authenticated+/settings+/webhooks.$id.tsx
@@ -5597,11 +5596,11 @@ msgstr "Ils ont la permission en votre nom de:"
#: apps/remix/app/components/forms/signin.tsx
msgid "This account has been disabled. Please contact support."
msgstr ""
msgstr "Ce compte a été désactivé. Veuillez contacter le support."
#: apps/remix/app/components/forms/signin.tsx
msgid "This account has not been verified. Please verify your account before signing in."
msgstr ""
msgstr "Ce compte n'a pas été vérifié. Veuillez vérifier votre compte avant de vous connecter."
#: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx
@@ -5647,7 +5646,7 @@ msgstr "Ce document a été annulé par le propriétaire."
#: apps/remix/app/routes/_authenticated+/documents.$id._index.tsx
msgid "This document has been rejected by a recipient"
msgstr ""
msgstr "Ce document a été rejeté par un destinataire"
#: apps/remix/app/routes/_authenticated+/documents.$id._index.tsx
msgid "This document has been signed by all recipients"
@@ -5799,7 +5798,7 @@ msgstr "Titre"
#: packages/ui/primitives/document-flow/add-settings.types.ts
msgid "Title cannot be empty"
msgstr ""
msgstr "Le titre ne peut pas être vide"
#: apps/remix/app/routes/_unauthenticated+/team.invite.$token.tsx
msgid "To accept this invitation you must create an account."
@@ -6164,7 +6163,7 @@ msgstr "Améliorer"
#: packages/lib/constants/document.ts
msgid "Upload"
msgstr ""
msgstr "Télécharger"
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Upload a CSV file to create multiple documents from this template. Each row represents one document with its recipient details."
@@ -6453,7 +6452,7 @@ msgstr "Vous voulez votre propre profil public ?"
#: packages/ui/primitives/document-flow/add-signers.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "Warning: Assistant as last signer"
msgstr ""
msgstr "Avertissement : Assistant comme dernier signataire"
#: apps/remix/app/components/general/billing-portal-button.tsx
#: apps/remix/app/components/general/teams/team-layout-billing-banner.tsx
@@ -6648,7 +6647,7 @@ msgstr "Nous n'avons pas pu vérifier vos détails. Veuillez réessayer ou conta
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "We were unable to verify your email at this time."
msgstr ""
msgstr "Nous n'avons pas pu vérifier votre email pour le moment."
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "We were unable to verify your email. If your email is not verified already, please try again."
@@ -6713,7 +6712,7 @@ msgstr "Webhooks"
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Weekly"
msgstr ""
msgstr "Hebdomadaire"
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Welcome"
@@ -6734,7 +6733,7 @@ msgstr "Essayiez-vous d'éditer ce document à la place ?"
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr ""
msgstr "Lorsqu'il est activé, les signataires peuvent choisir qui doit signer ensuite dans la séquence au lieu de suivre l'ordre prédéfini."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."
@@ -6880,7 +6879,7 @@ msgstr "Vous pouvez voir le document et son statut en cliquant sur le bouton ci-
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "You cannot add assistants when signing order is disabled."
msgstr ""
msgstr "Vous ne pouvez pas ajouter d'assistants lorsque l'ordre de signature est désactivé."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "You cannot have more than {MAXIMUM_PASSKEYS} passkeys."
@@ -6900,7 +6899,7 @@ msgstr "Vous ne pouvez pas importer de PDF cryptés"
#: apps/remix/app/components/general/billing-portal-button.tsx
msgid "You do not currently have a customer record, this should not happen. Please contact support for assistance."
msgstr ""
msgstr "Vous n'avez actuellement pas de dossier client, cela ne devrait pas se produire. Veuillez contacter le support pour obtenir de l'aide."
#: apps/remix/app/components/forms/token.tsx
msgid "You do not have permission to create a token for this team"
@@ -7055,7 +7054,7 @@ msgstr "Vous devez définir une URL de profil avant d'activer votre profil publi
#: apps/remix/app/routes/_authenticated+/settings+/tokens.tsx
msgid "You need to be an admin to manage API tokens."
msgstr ""
msgstr "Vous devez être administrateur pour gérer les tokens API."
#: apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx
msgid "You need to be logged in as <0>{email}</0> to view this page."
@@ -7159,7 +7158,7 @@ msgstr "Vos documents"
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "Your email has already been confirmed. You can now use all features of Documenso."
msgstr ""
msgstr "Votre email a déjà été confirmé. Vous pouvez maintenant utiliser toutes les fonctionnalités de Documenso."
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "Your email has been successfully confirmed! You can now use all features of Documenso."
@@ -7262,3 +7261,4 @@ msgstr "Votre token a été créé avec succès ! Assurez-vous de le copier car
#: apps/remix/app/routes/_authenticated+/settings+/tokens.tsx
msgid "Your tokens will be shown here once you create them."
msgstr "Vos tokens seront affichés ici une fois que vous les aurez créés."

Some files were not shown because too many files have changed in this diff Show More