Compare commits

...

34 Commits

Author SHA1 Message Date
921617b905 v1.7.0-rc.4 2024-09-05 10:46:23 +10:00
a1a8a174bf feat: embed signing experience (#1322) 2024-09-04 23:13:00 +10:00
3657050b02 fix: translation related crashes on marketing 2024-09-04 20:28:43 +10:00
210081c520 fix: a grammer error (#1316)
## Description

<!--- Describe the changes introduced by this pull request. -->
In this PR, I've fixed a small grammar error.

## Related Issue

Fixes: #1315 

## Changes Made

It replaces `"wont"` with `"won't"` and `"Only your signing experience
is"` with `"Only your signing experience will be"`

## Checklist

- [x] I have tested these changes locally and they work as expected.
- [x] I have added/updated tests that prove the effectiveness of these
changes.
- [x] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [x] I have addressed the code review feedback from the previous
submission, if applicable.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit


- **Bug Fixes**
- Updated text in the Document Share Button for enhanced clarity and
grammatical accuracy.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: ajeet <109718740+ajeetcode@users.noreply.github.com>
2024-09-03 13:16:14 +03:00
fd7c1fea1c chore: upgrade next (#1300) 2024-09-03 09:48:54 +10:00
5f4972d63b v1.7.0-rc.3 2024-09-03 09:27:51 +10:00
4c13176c52 feat: update createFields api endpoint (#1311)
Allow users to add 1 or more fields to a document via the /document/<id>/fields API Endpoint.
2024-09-02 21:16:48 +10:00
d599ab0630 v1.7.0-rc.2 2024-08-29 11:01:21 +10:00
9e714d607e feat: disable 2fa with backup codes (#1314)
Allow disabling two-factor authentication (2FA) by using either their
authenticator app (TOTP) or a backup code.
2024-08-29 11:00:57 +10:00
81479b5b55 v1.7.0-rc.1 2024-08-28 18:00:43 +10:00
15efc6c36d fix: broken pages by translation tags (#1312) 2024-08-28 17:58:56 +10:00
9638dfbf37 v1.7.0-rc.0 2024-08-28 14:31:30 +10:00
dfa89ffe44 fix: make invite and confirmations long lived (#1309)
Previously we would delete all invites and confirmation tokens upon
completing the action that they represent.

This change instead adds a flag on each token indicating whether it has
been completed so we can action a
completed token differently in the UI to reduce confusion for users.

This had been brought up a number of times where confirmation emails,
team member invites and other items
may have been actioned and forgotten about causing an error toast/page
upon subsequent revisit.
2024-08-28 14:08:35 +10:00
7943ed5353 chore: add translations (#1306) 2024-08-28 09:44:19 +10:00
cb50274450 fix: typo 2024-08-27 23:22:27 +09:00
04b92eac1d chore: add translations (#1305) 2024-08-27 23:37:05 +10:00
38a4b0f299 fix: dialog close on refresh (#1135)
Previously dialogs would be closed upon refocusing the browser tab due to router refetches occuring which would cause data-table columns to re-render. This is now resolved by extracting the column definitions outside of the returning render and into a memo hook.
2024-08-27 23:13:52 +10:00
75c8772a02 feat: web i18n (#1286) 2024-08-27 20:34:39 +09:00
0829311214 feat: prefill fields via api (#1261)
## Description

Configure the advanced field via API.

## Checklist

<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [x] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Enhanced API functionality to support field metadata during field
creation.
- Introduced validation checks for field metadata to ensure necessary
information is provided for advanced field types.

- **Bug Fixes**
- Improved error handling during field creation to return properly
formatted error responses.

- **Documentation**
- Updated API schemas to include field metadata, enhancing data
validation and response structures.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-08-26 12:37:56 +03:00
9223527b6f docs: fix documentation regarding the webhook secret header (#1278) 2024-08-21 14:03:21 +10:00
66fdc1d659 chore: update readme for manual self-hosting (#1270) 2024-08-21 11:08:04 +10:00
27066e2022 chore: add translations (#1295) 2024-08-21 11:06:26 +10:00
9178dbd3c1 chore: update marketing site 2024-08-20 23:23:36 +10:00
2c9136498c feat: update team email templates. (#1229)
Updates the email templates to include team information when sent
from a team context.
2024-08-20 15:41:19 +10:00
7a1341eb74 feat: automatically set public profile url for OIDC users (#1225)
Adds a hook to automatically set profile urls for OIDC users
2024-08-20 13:58:56 +10:00
06c0a50401 feat: copy and paste fields (#1193)
Adds keyboard shortcuts for copying and pasting fields, additionally adds the ability
to duplicate a field via the UI.
2024-08-20 13:32:53 +10:00
025e73e640 feat: advanced fields article (#1276) 2024-08-19 19:18:54 +10:00
73800d1503 fix: don't send too much data to background job provider 2024-08-15 13:57:54 +10:00
063ed966df fix: support custom inngest app ids 2024-08-14 16:26:58 +10:00
f568025a0b fix: support inngest vercel integration 2024-08-14 13:49:47 +10:00
ab8701526c fix: move sealing to a background job (#1287)
When signing a document the final signer is often
greeted with a super long completing spinner since
we are synchronously signing the document and sending
emails to all recipients. This is frustrating and
has caused issues for customers and self-hosters.

Moving sealing to a background job resolves this
and improves the overall snappiness of the app while
also supporting retrying the sealing if it were to
fail in the future.

This has the implication of a document no longer
immediately being in a "completed" state once all
signers have signed. To assist with this we now
refetch the page every 5 seconds upon signing
completion until the document status as shifted to
completed.
2024-08-14 13:12:32 +10:00
20ec2dde3d chore: update changelog 2024-08-13 11:49:12 +10:00
3b8914da83 v1.6.1-rc.1 2024-08-13 09:57:50 +10:00
29910ab2a7 feat: add initials field type (#1279)
Adds a new field type that enables document recipients to add
their `initials` on the document.
2024-08-12 23:29:32 +10:00
413 changed files with 20315 additions and 4448 deletions

View File

@ -3,7 +3,7 @@ description: 'Cache or restore if necessary'
inputs:
node_version:
required: false
default: v18.x
default: v20.x
runs:
using: 'composite'
steps:
@ -17,7 +17,7 @@ runs:
**/.turbo/**
**/dist/**
key: prod-build-${{ github.run_id }}
key: prod-build-${{ github.run_id }}-${{ hashFiles('package-lock.json') }}
restore-keys: prod-build-
- run: npm run build

View File

@ -2,7 +2,7 @@ name: 'Setup node and cache node_modules'
inputs:
node_version:
required: false
default: v18.x
default: v20.x
runs:
using: 'composite'

2
.gitignore vendored
View File

@ -1,5 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
packages/prisma/generated/types.ts
# dependencies
node_modules
.pnp

12
.vscode/settings.json vendored
View File

@ -5,12 +5,7 @@
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"eslint.validate": [
"typescript",
"typescriptreact",
"javascript",
"javascriptreact"
],
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
"javascript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.useAliasesForRenames": false,
"typescript.enablePromptUseWorkspaceTsdk": true,
@ -20,4 +15,7 @@
"[prisma]": {
"editor.defaultFormatter": "Prisma.prisma"
},
}
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

View File

@ -261,6 +261,7 @@ npm run prisma:migrate-deploy
Finally, you can start it with:
```
cd apps/web
npm run start
```

View File

@ -16,7 +16,7 @@
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"next": "14.0.3",
"next": "14.2.6",
"next-plausible": "^3.12.0",
"nextra": "^2.13.4",
"nextra-theme-docs": "^2.13.4",
@ -32,4 +32,4 @@
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}
}

View File

@ -12,5 +12,6 @@
"title": "API & Integration Guides"
},
"public-api": "Public API",
"embedding": "Embedding",
"webhooks": "Webhooks"
}
}

View File

@ -0,0 +1,131 @@
---
title: Get Started
description: Learn how to use embedding to bring signing to your own website or application
---
# Embedding
Our embedding feature lets you integrate our document signing experience into your own application or website. Whether you're building with React, Preact, Vue, Svelte, Solid, or using generalized web components, this guide will help you get started with embedding Documenso.
## Availability
Embedding is currently available for all users on a **Teams Plan** and above, as well as **Early Adopter's** within a team (Early Adopters can create a team for free).
In the future, we will roll out a **Platform Plan** that will offer additional enhancements for embedding, including the option to remove Documenso branding for a more customized experience.
## How Embedding Works
Embedding with Documenso allows you to handle document signing in two main ways:
1. **Using Direct Templates**: Using direct templates you can have an evergreen template that upon completion will create a new document within Documenso.
2. **Using a Signing Token**: A more advanced option for those running rich integrations with Documenso already. Given a recipients signing token you can embed the signing experience in your application rather than direct the recipient to Documenso.
_For most use-cases we recommend using direct templates, however if you have a need for a more advanced integration, we are happy to help you get started._
## Supported Frameworks
We support embedding across a range of popular JavaScript frameworks, including:
| Framework | Package |
| --------- | -------------------------------------------------------------------------------- |
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
Additionally, we provide **web components** for more generalized use. However, please note that web components are still in their early stages and haven't been extensively tested.
## Embedding with Direct Templates
#### Instructions
To get started with embedding using a Direct Template we will need the URL segment which is also referred to as the token for the template.
You can find your URL/Token by performing the following steps:
1. **Navigate to your team's templates within Documenso**
![Team Templates](/embedding/team-templates.png)
2. **Click on the direct link template you want to embed**
This will copy the URL to your clipboard, e.g. `https://stg-app.documenso.com/d/-WoSwWVT-fYOERS2MI37k`
**For the above url the token is `-WoSwWVT-fYOERS2MI37k`**
3. Provide the token to the `EmbedDirectTemplate` component in your frameworks SDK
```jsx
import { EmbedDirectTemplate } from '@documenso/embed-react';
const MyEmbeddingComponent = () => {
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
return <EmbedDirectTemplate token={token} />;
};
```
---
**Converting a regular template to a direct link template**
If you don't currently have any direct link templates you can easily create one by selecting the "Direct Link" option within the actions dropdown on the templates table.
This will show a dialog which will ask you to configure which recipient should be used as the direct link signer.
![Enable Direct Link Template](/embedding/enable-direct-link.png)
---
## Embedding with Signing Tokens
To embed the signing process for an ordinary document, youll need a **document signing token** for the recipient. This token provides the necessary access to load the document and facilitate the signing process securely.
#### Instructions
1. Retrieve the signing token for the recipient document you want to embed
This will typically be done using an API integration where signing tokens are provided as part of the response when creating a document. Alternatively you can manually get a signing link by clicking hovering over a recipients avatar and clicking their email on a document that you own.
![Copy Recipient Token](/embedding/copy-recipient-token.png)
With the signing url on our clipboard we can extract the token the same way we did for the direct link template.
So `https://stg-app.documenso.com/sign/lm7Tp2_yhvFfzdeJQzYQF` will become `lm7Tp2_yhvFfzdeJQzYQF`
2. Provide the token to the `EmbedSignDocument` component in your frameworks SDK
```jsx
import { EmbedSignDocument } from '@documenso/embed-react';
const MyEmbeddingComponent = () => {
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
return <EmbedSignDocument token={token} />;
};
```
---
## Using Embedding in Your Application
Once you've obtained the appropriate tokens, you can integrate the signing experience into your application. For framework-specific instructions, please refer to the guides provided in our documentation for:
- [React](/developers/embedding/react)
- [Preact](/developers/embedding/preact)
- [Vue](/developers/embedding/vue)
- [Svelte](/developers/embedding/svelte)
- [Solid](/developers/embedding/solid)
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.
## Stay Tuned for the Platform Plan
While embedding is already a powerful tool, we're working on a **Platform Plan** that will introduce even more functionality. This plan will offer:
- Additional customization options
- The ability to remove Documenso branding
- Additional controls for the signing experience
More details will be shared as we approach the release.

View File

@ -0,0 +1,77 @@
---
title: Preact Integration
description: Learn how to use our embedding SDK within your Preact application.
---
# Preact Integration
Our Preact SDK provides a simple way to embed a signing experience within your Preact application. It supports both direct link templates and signing tokens.
## Installation
To install the SDK, run the following command:
```bash
npm install @documenso/embed-preact
```
## Usage
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
### Direct Link Template
If you have a direct link template, you can simply provide the token for the template to the `EmbedDirectTemplate` component.
```jsx
import { EmbedDirectTemplate } from '@documenso/embed-preact';
const MyEmbeddingComponent = () => {
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
return <EmbedDirectTemplate token={token} />;
};
```
#### Props
| Prop | Type | Description |
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| token | string | The token for the document you want to embed |
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
| name | string (optional) | The name the signer that will be used by default for signing |
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
| email | string (optional) | The email the signer that will be used by default for signing |
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
| onFieldSigned | function (optional) | A callback function that will be called when a field has been signed |
| onFieldUnsigned | function (optional) | A callback function that will be called when a field has been unsigned |
### Signing Token
If you have a signing token, you can provide it to the `EmbedSignDocument` component.
```jsx
import { EmbedSignDocument } from '@documenso/embed-preact';
const MyEmbeddingComponent = () => {
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
return <EmbedSignDocument token={token} />;
};
```
#### Props
| Prop | Type | Description |
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| token | string | The token for the document you want to embed |
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
| name | string (optional) | The name the signer that will be used by default for signing |
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |

View File

@ -0,0 +1,77 @@
---
title: React Integration
description: Learn how to use our embedding SDK within your React application.
---
# React Integration
Our React SDK provides a simple way to embed a signing experience within your React application. It supports both direct link templates and signing tokens.
## Installation
To install the SDK, run the following command:
```bash
npm install @documenso/embed-react
```
## Usage
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
### Direct Link Template
If you have a direct link template, you can simply provide the token for the template to the `EmbedDirectTemplate` component.
```jsx
import { EmbedDirectTemplate } from '@documenso/embed-react';
const MyEmbeddingComponent = () => {
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
return <EmbedDirectTemplate token={token} />;
};
```
#### Props
| Prop | Type | Description |
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| token | string | The token for the document you want to embed |
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
| name | string (optional) | The name the signer that will be used by default for signing |
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
| email | string (optional) | The email the signer that will be used by default for signing |
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
| onFieldSigned | function (optional) | A callback function that will be called when a field has been signed |
| onFieldUnsigned | function (optional) | A callback function that will be called when a field has been unsigned |
### Signing Token
If you have a signing token, you can provide it to the `EmbedSignDocument` component.
```jsx
import { EmbedSignDocument } from '@documenso/embed-react';
const MyEmbeddingComponent = () => {
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
return <EmbedSignDocument token={token} />;
};
```
#### Props
| Prop | Type | Description |
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| token | string | The token for the document you want to embed |
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
| name | string (optional) | The name the signer that will be used by default for signing |
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |

View File

@ -0,0 +1,77 @@
---
title: Solid.js Integration
description: Learn how to use our embedding SDK within your Solid.js application.
---
# Solid.js Integration
Our Solid.js SDK provides a simple way to embed a signing experience within your Solid.js application. It supports both direct link templates and signing tokens.
## Installation
To install the SDK, run the following command:
```bash
npm install @documenso/embed-solid
```
## Usage
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
### Direct Link Template
If you have a direct link template, you can simply provide the token for the template to the `EmbedDirectTemplate` component.
```jsx
import { EmbedDirectTemplate } from '@documenso/embed-solid';
const MyEmbeddingComponent = () => {
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
return <EmbedDirectTemplate token={token} />;
};
```
#### Props
| Prop | Type | Description |
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| token | string | The token for the document you want to embed |
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
| name | string (optional) | The name the signer that will be used by default for signing |
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
| email | string (optional) | The email the signer that will be used by default for signing |
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
| onFieldSigned | function (optional) | A callback function that will be called when a field has been signed |
| onFieldUnsigned | function (optional) | A callback function that will be called when a field has been unsigned |
### Signing Token
If you have a signing token, you can provide it to the `EmbedSignDocument` component.
```jsx
import { EmbedSignDocument } from '@documenso/embed-solid';
const MyEmbeddingComponent = () => {
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
return <EmbedSignDocument token={token} />;
};
```
#### Props
| Prop | Type | Description |
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| token | string | The token for the document you want to embed |
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
| name | string (optional) | The name the signer that will be used by default for signing |
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |

View File

@ -0,0 +1,79 @@
---
title: Svelte Integration
description: Learn how to use our embedding SDK within your Svelte application.
---
# Svelte Integration
Our Svelte SDK provides a simple way to embed a signing experience within your Svelte application. It supports both direct link templates and signing tokens.
## Installation
To install the SDK, run the following command:
```bash
npm install @documenso/embed-svelte
```
## Usage
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
### Direct Link Template
If you have a direct link template, you can simply provide the token for the template to the `EmbedDirectTemplate` component.
```html
<script lang="ts">
import { EmbedDirectTemplate } from '@documenso/embed-svelte';
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
</script>
<template>
<EmbedDirectTemplate {token} />
</template>
```
#### Props
| Prop | Type | Description |
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| token | string | The token for the document you want to embed |
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
| name | string (optional) | The name the signer that will be used by default for signing |
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
| email | string (optional) | The email the signer that will be used by default for signing |
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
| onFieldSigned | function (optional) | A callback function that will be called when a field has been signed |
| onFieldUnsigned | function (optional) | A callback function that will be called when a field has been unsigned |
### Signing Token
If you have a signing token, you can provide it to the `EmbedSignDocument` component.
```jsx
import { EmbedSignDocument } from '@documenso/embed-svelte';
const MyEmbeddingComponent = () => {
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
return <EmbedSignDocument token={token} />;
};
```
#### Props
| Prop | Type | Description |
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| token | string | The token for the document you want to embed |
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
| name | string (optional) | The name the signer that will be used by default for signing |
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |

View File

@ -0,0 +1,79 @@
---
title: Vue Integration
description: Learn how to use our embedding SDK within your Vue application.
---
# Vue Integration
Our Vue SDK provides a simple way to embed a signing experience within your Vue application. It supports both direct link templates and signing tokens.
## Installation
To install the SDK, run the following command:
```bash
npm install @documenso/embed-vue
```
## Usage
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
### Direct Link Template
If you have a direct link template, you can simply provide the token for the template to the `EmbedDirectTemplate` component.
```html
<script setup lang="ts">
import { EmbedDirectTemplate } from '@documenso/embed-vue';
const token = ref('YOUR_TOKEN_HERE'); // Replace with the actual token
</script>
<template>
<EmbedDirectTemplate :token="token" />
</template>
```
#### Props
| Prop | Type | Description |
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| token | string | The token for the document you want to embed |
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
| name | string (optional) | The name the signer that will be used by default for signing |
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
| email | string (optional) | The email the signer that will be used by default for signing |
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
| onFieldSigned | function (optional) | A callback function that will be called when a field has been signed |
| onFieldUnsigned | function (optional) | A callback function that will be called when a field has been unsigned |
### Signing Token
If you have a signing token, you can provide it to the `EmbedSignDocument` component.
```jsx
import { EmbedSignDocument } from '@documenso/embed-vue';
const MyEmbeddingComponent = () => {
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
return <EmbedSignDocument token={token} />;
};
```
#### Props
| Prop | Type | Description |
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| token | string | The token for the document you want to embed |
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
| name | string (optional) | The name the signer that will be used by default for signing |
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |

View File

@ -37,7 +37,7 @@ To create a new webhook subscription, you need to provide the following informat
- Enter the webhook URL that will receive the event payload.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`.
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Signature` header of the request.
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp)

View File

@ -15,7 +15,7 @@ The signature field collects the signer's signature. It's required for each reci
The field doesn't have any additional settings. You just need to place it on the document where you want the signer to sign.
![The signature field in the Documenso document editor](public/document-signing/signature-field-document-editor-view.webp)
![The signature field in the Documenso document editor](/document-signing/signature-field-document-editor-view.webp)
### Document Signing View
@ -23,11 +23,11 @@ The recipient will see the signature field when they open the document to sign.
The recipient must click on the signature field to open the signing view, where they can sign using their mouse, touchpad, or touchscreen.
![The signature field in the Documenso document signing view](public/document-signing/signature-field-document-signing-view.webp)
![The signature field in the Documenso document signing view](/document-signing/signature-field-document-signing-view.webp)
The image below shows the signature field signed by the recipient.
![The signature field signed by the user in the Documenso document signing view](public/document-signing/signed-signature-field.webp)
![The signature field signed by the user in the Documenso document signing view](/document-signing/signed-signature-field.webp)
After signing, the recipient can click the "Complete" button to complete the signing process.
@ -39,7 +39,7 @@ The email field is used to collect the signer's email address.
The field doesn't have any additional settings. You just need to place it on the document where you want the signer to sign.
![The email field in the Documenso document editor](public/document-signing/email-field-document-editor-view.webp)
![The email field in the Documenso document editor](/document-signing/email-field-document-editor-view.webp)
### Document Signing View
@ -47,11 +47,11 @@ When the recipient opens the document to sign, they will see the email field.
The recipient must click on the email field to automatically sign the field with the email associated with their account.
![The email field in the Documenso document signing view](public/document-signing/email-field-document-signing-view.webp)
![The email field in the Documenso document signing view](/document-signing/email-field-document-signing-view.webp)
The image below shows the email field signed by the recipient.
![The email field signed by the user in the Documenso document signing view](public/document-signing/signed-email-field.webp)
![The email field signed by the user in the Documenso document signing view](/document-signing/signed-email-field.webp)
After entering their email address, the recipient can click the "Complete" button to complete the signing process.
@ -63,7 +63,7 @@ The name field is used to collect the signer's name.
The field doesn't have any additional settings. You just need to place it on the document where you want the signer to sign.
![The name field in the Documenso document editor](public/document-signing/name-field-document-editor-view.webp)
![The name field in the Documenso document editor](/document-signing/name-field-document-editor-view.webp)
### Document Signing View
@ -71,11 +71,11 @@ When the recipient opens the document to sign, they will see the name field.
The recipient must click on the name field, which will automatically sign the field with the name associated with their account.
![The name field in the Documenso document signing view](public/document-signing/name-field-document-signing-view.webp)
![The name field in the Documenso document signing view](/document-signing/name-field-document-signing-view.webp)
The image below shows the name field signed by the recipient.
![The name field signed by the user in the Documenso document signing view](public/document-signing/name-field-signed.webp)
![The name field signed by the user in the Documenso document signing view](/document-signing/name-field-signed.webp)
After entering their name, the recipient can click the "Complete" button to complete the signing process.
@ -87,7 +87,7 @@ The date field is used to collect the date of the signature.
The field doesn't have any additional settings. You just need to place it on the document where you want the signer to sign.
![The date field in the Documenso document editor](public/document-signing/date-field-document-editor-view.webp)
![The date field in the Documenso document editor](/document-signing/date-field-document-editor-view.webp)
### Document Signing View
@ -95,11 +95,11 @@ When the recipient opens the document to sign, they will see the date field.
The recipient must click on the date field to automatically sign the field with the current date and time.
![The date field in the Documenso document signing view](public/document-signing/date-field-document-signing-view.webp)
![The date field in the Documenso document signing view](/document-signing/date-field-document-signing-view.webp)
The image below shows the date field signed by the recipient.
![The date field signed by the user in the Documenso document signing view](public/document-signing/date-field-signed.webp)
![The date field signed by the user in the Documenso document signing view](/document-signing/date-field-signed.webp)
After entering the date, the recipient can click the "Complete" button to complete the signing process.
@ -111,11 +111,11 @@ The text field is used to collect text input from the signer.
Place the text field on the document where you want the signer to enter text. The text field comes with additional settings that can be configured.
![The text field in the Documenso document editor](public/document-signing/text-field-document-editor-view.webp)
![The text field in the Documenso document editor](/document-signing/text-field-document-editor-view.webp)
To open the settings, click on the text field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
![The text field settings in the Documenso document editor](public/document-signing/text-field-advanced-settings-document-editor-view.webp)
![The text field settings in the Documenso document editor](/document-signing/text-field-advanced-settings-document-editor-view.webp)
The text field settings include:
@ -137,7 +137,7 @@ It also comes with a couple of rules:
Let's look at the following example.
![A text field with the settings configured by the user in the Documenso document editor](public/document-signing/text-field-with-filled-advanced-settings.webp)
![A text field with the settings configured by the user in the Documenso document editor](/document-signing/text-field-with-filled-advanced-settings.webp)
The field is configured as follows:
@ -156,23 +156,23 @@ What the recipient sees when they open the document to sign depends on the setti
In this case, the recipient sees the text field signed with the default value.
![Text field with the default value signed by the user in the Documenso document signing view](public/document-signing/text-field-autosigned.webp)
![Text field with the default value signed by the user in the Documenso document signing view](/document-signing/text-field-autosigned.webp)
The recipient can modify the text field value since the field is not read-only. To change the value, the recipient must click the field to un-sign it.
Once it's unsigned, the field uses the label set by the sender.
![Unsigned text field in the Documenso document signing view](public/document-signing/text-field-unsigned.webp)
![Unsigned text field in the Documenso document signing view](/document-signing/text-field-unsigned.webp)
To sign the field with a different value, the recipient needs to click on the field and enter the new value.
![Text field with the text input in the Documenso document signing view](public/document-signing/text-field-input-modal.webp)
![Text field with the text input in the Documenso document signing view](/document-signing/text-field-input-modal.webp)
Since the text field has a character limit, the recipient must enter a value that doesn't exceed the limit. Otherwise, an error message will appear, and the field will not be signed.
The image below illustrates the text field signed with a new value.
![Text field signed with a new value](public/document-signing/text-field-new-value-signed.webp)
![Text field signed with a new value](/document-signing/text-field-new-value-signed.webp)
After signing the field, the recipient can click the "Complete" button to complete the signing process.
@ -184,11 +184,11 @@ The number field is used for collecting a number input from the signer.
Place the number field on the document where you want the signer to enter a number. The number field comes with additional settings that can be configured.
![The number field in the Documenso document editor](public/document-signing/number-field-document-editor.webp)
![The number field in the Documenso document editor](/document-signing/number-field-document-editor.webp)
To open the settings, click on the number field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
![The number field in the Documenso document editor](public/document-signing/number-field-document-editor-view.webp)
![The number field in the Documenso document editor](/document-signing/number-field-document-editor-view.webp)
The number field settings include:
@ -221,7 +221,7 @@ In this example, the number field is configured as follows:
- Validation:
- Min value: 5, Max value: 50
![A number field with the label configured by the user in the Documenso document editor](public/document-signing/number-field-label.webp)
![A number field with the label configured by the user in the Documenso document editor](/document-signing/number-field-label.webp)
Since the field has a label set, the label is displayed instead of the default number field value - "Add number".
@ -231,23 +231,23 @@ What the recipient sees when they open the document to sign depends on the setti
The recipient sees the number field signed with the default value in this case.
![Number field with the default value signed by the user in the Documenso document signing view](public/document-signing/number-field-autosigned.webp)
![Number field with the default value signed by the user in the Documenso document signing view](/document-signing/number-field-autosigned.webp)
Since the number field is not read-only, the recipient can modify its value. To change the value, the recipient must click the field to un-sign it.
Once it's unsigned, the field uses the label set by the sender.
![Unsigned number field in the Documenso document signing view](public/document-signing/number-field-unsigned.webp)
![Unsigned number field in the Documenso document signing view](/document-signing/number-field-unsigned.webp)
To sign the field with a different value, the recipient needs to click on the field and enter the new value.
![Number field with the number input in the Documenso document signing view](public/document-signing/number-field-input-modal.webp)
![Number field with the number input in the Documenso document signing view](/document-signing/number-field-input-modal.webp)
Since the number field has a validation rule set, the recipient must enter a value that meets the rules. In this example, the value needs to be between 5 and 50. Otherwise, an error message will appear, and the field will not be signed.
The image below illustrates the text field signed with a new value.
![Number field signed with a new value](public/document-signing/number-field-signed-with-another-value.webp)
![Number field signed with a new value](/document-signing/number-field-signed-with-another-value.webp)
After signing the field, the recipient can click the "Complete" button to complete the signing process.
@ -259,11 +259,11 @@ The radio field is used to collect a single choice from the signer.
Place the radio field on the document where you want the signer to select a choice. The radio field comes with additional settings that can be configured.
![The radio field in the Documenso document editor](public/document-signing/radio-field-document-editor-view.webp)
![The radio field in the Documenso document editor](/document-signing/radio-field-document-editor-view.webp)
To open the settings, click on the radio field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
![The radio field advanced settings in the Documenso document editor](public/document-signing/radio-field-advanced-settings-document-editor-view.webp)
![The radio field advanced settings in the Documenso document editor](/document-signing/radio-field-advanced-settings-document-editor-view.webp)
The radio field settings include:
@ -293,7 +293,7 @@ In this example, the radio field is configured as follows:
- Empty value
- Option 3
![The radio field with the settings configured by the user in the Documenso document editor](public/document-signing/radio-field-options-document-editor-view.webp)
![The radio field with the settings configured by the user in the Documenso document editor](/document-signing/radio-field-options-document-editor-view.webp)
Since the field contains radio options, it displays them instead of the default radio field value, "Radio".
@ -303,11 +303,11 @@ What the recipient sees when they open the document to sign depends on the setti
In this case, the recipient sees the radio field unsigned because the sender didn't select a value.
![Radio field with no value selected in the Documenso document signing view](public/document-signing/radio-field-unsigned.webp)
![Radio field with no value selected in the Documenso document signing view](/document-signing/radio-field-unsigned.webp)
The recipient can select one of the options by clicking on the radio button next to the option.
![Radio field with the value selected by the user in the Documenso document signing view](public/document-signing/radio-field-signed.webp)
![Radio field with the value selected by the user in the Documenso document signing view](/document-signing/radio-field-signed.webp)
After signing the field, the recipient can click the "Complete" button to complete the signing process.
@ -319,11 +319,11 @@ The checkbox field is used to collect multiple choices from the signer.
Place the checkbox field on the document where you want the signer to select choices. The checkbox field comes with additional settings that can be configured.
![The checkbox field in the Documenso document editor](public/document-signing/checkbox-document-editor-view.webp)
![The checkbox field in the Documenso document editor](/document-signing/checkbox-document-editor-view.webp)
To open the settings, click on the checkbox field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
![The checkbox field settings in the Documenso document editor](public/document-signing/checkbox-advanced-settings.webp)
![The checkbox field settings in the Documenso document editor](/document-signing/checkbox-advanced-settings.webp)
The checkbox field settings include the following:
@ -356,7 +356,7 @@ In this example, the checkbox field is configured as follows:
- Option 3 (checked)
- Empty value
![The checkbox field with the settings configured by the user in the Documenso document editor](public/document-signing/checkbox-advanced-settings-document-editor-view.webp)
![The checkbox field with the settings configured by the user in the Documenso document editor](/document-signing/checkbox-advanced-settings-document-editor-view.webp)
Since the field contains checkbox options, it displays them instead of the default checkbox field value, "Checkbox".
@ -366,7 +366,7 @@ What the recipient sees when they open the document to sign depends on the setti
In this case, the recipient sees the checkbox field signed with the values selected by the sender.
![Checkbox field with the values selected by the user in the Documenso document signing view](public/document-signing/checkbox-field-document-signing-view.webp)
![Checkbox field with the values selected by the user in the Documenso document signing view](/document-signing/checkbox-field-document-signing-view.webp)
Since the field is required, the recipient can either sign with the values selected by the sender or modify the values.
@ -377,11 +377,11 @@ The values can be modified in 2 ways:
The image below illustrates the checkbox field with the values cleared by the recipient. Since the field is required, it has a red border instead of the yellow one (non-required fields).
![Checkbox field the values cleared by the user in the Documenso document signing view](public/document-signing/checkbox-field-unsigned.webp)
![Checkbox field the values cleared by the user in the Documenso document signing view](/document-signing/checkbox-field-unsigned.webp)
Then, the recipient can select values other than the ones chosen by the sender.
![Checkbox field with the values selected by the user in the Documenso document signing view](public/document-signing/checkbox-field-custom-sign.webp)
![Checkbox field with the values selected by the user in the Documenso document signing view](/document-signing/checkbox-field-custom-sign.webp)
After signing the field, the recipient can click the "Complete" button to complete the signing process.
@ -393,11 +393,11 @@ The dropdown/select field collects a single choice from a list of options.
Place the dropdown/select field on the document where you want the signer to select a choice. The dropdown/select field comes with additional settings that can be configured.
![The dropdown/select field in the Documenso document editor](public/document-signing/select-field-sliders.webp)
![The dropdown/select field in the Documenso document editor](/document-signing/select-field-sliders.webp)
To open the settings, click on the dropdown/select field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
![The dropdown/select field settings in the Documenso document editor](public/document-signing/select-field-advanced-settings-panel.webp)
![The dropdown/select field settings in the Documenso document editor](/document-signing/select-field-advanced-settings-panel.webp)
The dropdown/select field settings include:
@ -433,14 +433,14 @@ What the recipient sees when they open the document to sign depends on the setti
In this case, the recipient sees the dropdown/select field with the default label, "-- Select ---" since the sender has not set a default value.
![Dropdown/select field in the Documenso document signing view](public/document-signing/select-field-unsigned.webp)
![Dropdown/select field in the Documenso document signing view](/document-signing/select-field-unsigned.webp)
The recipient can modify the dropdown/select field value since the field is not read-only. To change the value, the recipient must click on the field for the dropdown list to appear.
![Dropdown/select field with the dropdown list in the Documenso document signing view](public/document-signing/select-field-unsigned-dropdown.webp)
![Dropdown/select field with the dropdown list in the Documenso document signing view](/document-signing/select-field-unsigned-dropdown.webp)
The recipient can select one of the options from the list. The image below illustrates the dropdown/select field signed with a new value.
![Dropdown/select field signed with a value](public/document-signing/select-field-signed.webp)
![Dropdown/select field signed with a value](/document-signing/select-field-signed.webp)
After signing the field, the recipient can click the "Complete" button to complete the signing process.

View File

@ -18,17 +18,17 @@ The guide assumes you have a Documenso account. If you don't, you can create a f
Navigate to the [Documenso dashboard](https://app.documenso.com/documents) and click on the "Add a document" button. Select the document you want to upload and wait for the upload to complete.
![Documenso dashboard](public/document-signing/documenso-documents-dashboard.webp)
![Documenso dashboard](/document-signing/documenso-documents-dashboard.webp)
After the upload is complete, you will be redirected to the document's page. You can configure the document's settings and add recipients and fields here.
![Documenso document overview](public/document-signing/documenso-uploaded-document.webp)
![Documenso document overview](/document-signing/documenso-uploaded-document.webp)
### (Optional) Advanced Options
Click on the "Advanced options" button to access additional settings for the document. You can set an external ID, date format, time zone, and the redirect URL.
![Advanced settings for a document uploaded in the Documenso dashboard](public/document-signing/documenso-uploaded-document-advanced-options.webp)
![Advanced settings for a document uploaded in the Documenso dashboard](/document-signing/documenso-uploaded-document-advanced-options.webp)
The external ID allows you to set a custom ID for the document that can be used to identify the document in your external system(s).
@ -45,7 +45,7 @@ The available options are:
- **Require account** - The recipient must be signed in to view the document.
- **None** - The document can be accessed directly by the URL sent to the recipient.
![Document access settings in the Documenso dashboard](public/document-signing/documenso-enterprise-document-access.webp)
![Document access settings in the Documenso dashboard](/document-signing/documenso-enterprise-document-access.webp)
<Callout type="info">
The "Document Access" feature is only available for Enterprise accounts.
@ -61,7 +61,7 @@ The available options are:
- **Require 2FA** - The recipient must have an account and 2FA enabled via their settings.
- **None** - No authentication required.
![Document recipient action authentication in the Documenso dashboard](public/document-signing/document-enterprise-recipient-action-authentication.webp)
![Document recipient action authentication in the Documenso dashboard](/document-signing/document-enterprise-recipient-action-authentication.webp)
This can be overridden by setting the authentication requirements directly for each recipient in the next step.
@ -75,11 +75,11 @@ Click the "+ Add Signer" button to add a new recipient. You can configure the re
You can choose any option from the ["Recipient Authentication"](#optional-recipient-authentication) section, or you can set it to "Inherit authentication method" to use the global action signing authentication method configured in the "General Settings" step.
![The required authentication method for a recipient](public/document-signing/documenso-document-recipient-authentication-method.webp)
![The required authentication method for a recipient](/document-signing/documenso-document-recipient-authentication-method.webp)
You can also set the recipient's role, which determines their actions and permissions in the document.
![The recipient role](public/document-signing/documenso-document-recipient-role.webp)
![The recipient role](/document-signing/documenso-document-recipient-role.webp)
#### Roles
@ -96,7 +96,7 @@ Documenso has 4 roles for recipients with different permissions and actions.
Documenso supports 9 different field types that can be added to the document. Each field type collects various information from the recipients when they sign the document.
![The available field types in the Documenso dashboard](public/document-signing/documenso-document-fields.webp)
![The available field types in the Documenso dashboard](/document-signing/documenso-document-fields.webp)
The available field types are:
@ -121,13 +121,13 @@ All fields can be placed anywhere on the document and resized as needed.
Signer Roles require at least 1 signature field. You will get an error message if you try to send a document without a signature field.
![Error message when trying to send a document without a signature field](public/document-signing/documenso-no-signature-field-found.webp)
![Error message when trying to send a document without a signature field](/document-signing/documenso-no-signature-field-found.webp)
### Email Settings
Before sending the document, you can configure the email settings and customize the subject line, message, and sender name.
![Email settings in the Documenso dashboard](public/document-signing/documenso-document-email-settings.webp)
![Email settings in the Documenso dashboard](/document-signing/documenso-document-email-settings.webp)
If you leave the email subject and message empty, Documenso will use the default email template.
@ -135,13 +135,13 @@ If you leave the email subject and message empty, Documenso will use the default
After configuring the document, click the "Send" button to send the document to the recipients. The recipients will receive an email with a link to sign the document.
![The email sent to the recipients with the signing link](public/document-signing/documenso-sign-email.webp)
![The email sent to the recipients with the signing link](/document-signing/documenso-sign-email.webp)
#### Signing Link
If you need to copy the signing link for each recipient, you can do so by clicking on the recipient whose link you want to copy. The signing link is copied automatically to your clipboard.
![How to copy the signing link for each recipient from the Documenso dashboard](public/document-signing/documenso-signing-links.webp)
![How to copy the signing link for each recipient from the Documenso dashboard](/document-signing/documenso-signing-links.webp)
The signing link has the following format:

View File

@ -10,15 +10,15 @@ Documenso allows you to create templates, which are reusable documents. Template
To create a new template, navigate to the ["Templates" page](https://app.documenso.com/templates) and click on the "New Template" button.
![Documenso template page](public/templates/documenso-template-page.webp)
![Documenso template page](/templates/documenso-template-page.webp)
Clicking on the "New Template" button opens a new modal to upload the document you want to use as a template. Select the document and wait for Documenso to upload it to your account.
![Upload a new template document in the Documenso dashboard](public/templates/documenso-template-page-upload-document.webp)
![Upload a new template document in the Documenso dashboard](/templates/documenso-template-page-upload-document.webp)
Once the upload is complete, Documenso opens the template configuration page.
![A successfuly uploaded document in the Documenso dashboard](public/templates/documenso-template-page-uploaded-document.webp)
![A successfuly uploaded document in the Documenso dashboard](/templates/documenso-template-page-uploaded-document.webp)
You can then configure the template by adding recipients, fields, and other options.
@ -28,7 +28,7 @@ When you send a document for signing, Documenso emails the recipients with a lin
Documenso uses a generic subject and message but also allows you to customize them for each document and template.
![Configuring the email options for the Documenso template](public/templates/documenso-template-page-uploaded-document-email-options.webp)
![Configuring the email options for the Documenso template](/templates/documenso-template-page-uploaded-document-email-options.webp)
To configure the email options, click the "Email Options" tab and fill in the subject and message fields. Every time you use this template for signing, Documenso will use the custom subject and message you provided. They can also be overridden before sending the document.
@ -36,7 +36,7 @@ To configure the email options, click the "Email Options" tab and fill in the su
The template also has advanced options that you can configure. These options include settings such as the external ID, date format, time zone and the redirect URL.
![Configuring the advanced options for the Documenso template](public/templates/documenso-template-page-uploaded-document-advanced-options.webp)
![Configuring the advanced options for the Documenso template](/templates/documenso-template-page-uploaded-document-advanced-options.webp)
The external ID allows you to set a custom ID for the document that can be used to identify the document in your external system(s).
@ -50,7 +50,7 @@ You can add placeholders for the template recipients. Placeholders specify where
You can also add recipients directly to the template. Recipients are the people who will receive the document for signing.
![Adding placeholder recipients for the Documenso template](public/templates/documenso-template-recipients.webp)
![Adding placeholder recipients for the Documenso template](/templates/documenso-template-recipients.webp)
If you add placeholders to the template, you must replace them with actual recipients when creating a document from it. See the modal from the ["Use a Template"](#use-a-template) section.
@ -70,7 +70,7 @@ Documenso provides the following field types:
- **Checkbox** - Collects multiple choices from the signer
- **Dropdown/Select** - Collects a single choice from a list of choices
![Adding fields for the Documenso template](public/templates/documenso-template-page-fields.webp)
![Adding fields for the Documenso template](/templates/documenso-template-page-fields.webp)
After adding the fields, press the "Save Template" button to save the template.
@ -85,7 +85,7 @@ Click on the "Use Template" button to create a new document from the template. B
After filling in the recipients, click the "Create Document" button to create the document in your account.
![Use an available Documenso template](public/templates/documenso-use-template.webp)
![Use an available Documenso template](/templates/documenso-use-template.webp)
You can also send the document straight to the recipients for signing by checking the "Send document" checkbox.

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View File

@ -11,14 +11,7 @@ tags:
- Compliance
---
<video
id="vid"
width="100%"
src="/blog/vial.webm"
autoPlay
loop
muted
></video>
<video id="vid" width="100%" src="/blog/vial.webm" autoPlay loop muted></video>
<figcaption className="text-center">
Vial.com uses Documenso for 21 CFR Part 11 compliant signing.
</figcaption>
@ -26,42 +19,40 @@ tags:
> TLDR; We launched Vial.com on Documenso and are open for 21 CFR Part 11 business.
# What is 21 CFR
You have never heard of 21 CFR Part 11? You are in good company since most people haven't. If you have, you probably work in an industry regulated by the U.S. Food and Drug Administration (FDA). Title 21 of the Code of Federal Regulations (CFR) is dedicated to detailing FDA-regulated business, and sub-part 11 sets out guidelines for using electronic signatures in this highly regulated field. Hence, 21 CFR Part 11 is highly relevant for regulated industries that aim to employ digital signatures. The guidelines set out in 21 CFR Part 11 aim to provide trustworthy, reliable, and equivalent to paper records and handwritten signatures. All Industries that fall under the FDA's regulation, e.g. pharmaceuticals, biotechnology, medical devices, and biologics, must comply with these rules when choosing or creating systems for electronic signatures.
You have never heard of 21 CFR Part 11? You are in good company since most people haven't. If you have, you probably work in an industry regulated by the U.S. Food and Drug Administration (FDA). Title 21 of the Code of Federal Regulations (CFR) is dedicated to detailing FDA-regulated business, and sub-part 11 sets out guidelines for using electronic signatures in this highly regulated field. Hence, 21 CFR Part 11 is highly relevant for regulated industries that aim to employ digital signatures. The guidelines set out in 21 CFR Part 11 aim to provide trustworthy, reliable, and equivalent to paper records and handwritten signatures. All Industries that fall under the FDA's regulation, e.g. pharmaceuticals, biotechnology, medical devices, and biologics, must comply with these rules when choosing or creating systems for electronic signatures.
Compliance with 21 CFR Part 11 is crucial for companies to use electronic records and signatures in their operations legally. It affects how companies manage documentation, conduct audits, and maintain regulatory submissions. Non-compliance can result in legal penalties, rejected submissions, and delays in product approvals, emphasizing the importance of adhering to these guidelines in FDA-regulated activities.
# Vial.com
Vial is a technology company on a mission to advance programs to market through computationally designed therapeutics and cost-effective clinical trials. It is imperative that Vial manages this process securely, effectively, and highly compliant. By leveraging it's modern platform, Vial aims to accelerate drug development and, ultimately, time to market for new therapies. You can learn more about them [here](https://vial.com/about-us).
[Together](https://documen.so/vial-documenso), Documenso and Vial set out to create the first open-source, 21 CFR Part 11 compliant signing solution. After iterating over the product together, Vial moved their operation from DocuSign, a known legacy signing provider, to a Documenso Enterprise plan. We are very happy to be able to support Vials mission by fulfilling our own: bringing open signing and all its innovation to where it's needed.
# 21 CFR Part 11 on Documenso Highlights
21 CFR Part 11 is a highly complex statute, and going into the all design rationales and the following implementation details, deserves its own article later. For now, I want to share a few notable highlights.
## The Full Experience
We implemented 21 CFR Part 11, keeping the main user experience of Documenso intact. Our 21 CFR module is not separate but natively integrated into all Documenso flows, thus not sacrificing usability for compliance. This also means most (if not all) advanced features we offer are usable in a compliant way. This prevents customers from being trapped in an anti-innovation bubble, not allowing access to new features for fear of non-compliance.
## Action Reauth Using Passkeys
<video
id="vid"
width="100%"
src="/blog/vial2.webm"
autoPlay
loop
muted
controls
></video>
<video id="vid" width="100%" src="/blog/vial2.webm" autoPlay loop muted controls></video>
<figcaption className="text-center">
Using passkeys (used here via fingerprint scanner) is the smoothest way to re-authenticate.
</figcaption>
One of the requirements affecting day-to-day life the most is the requirement to actually reauthenticate every signature placed on a document. While we can't change that, we can help make the reauthentication as painless as possible. To this end, we opted for passkeys. While Documenso supports passkeys to log in, they are also supported to authenticate signing on a per-signature level as part of the Documenso Enterprise Plan. The user still has to authenticate every signature but can now do so from the comfort of their passkey provider, be that 1Password, their browser, or any other provider.
## Direct Links
We recently launched [Direct Template Links](https://documen.so/direct-links), a new way to let people sign and fill out forms. Links can be completed anytime, creating a new document in the process. Direct Links are also 21 CFR part 11 compliant, using action reauthentication, audit log, and all other compliance requirements.
# Documenso Enterprise Plan
With the successful launch of Vial, we are now open for business. 21 CFR Part 11 compliance is part of the Documenso Enterprise plan, which includes all regulations we currently support and upcoming additions. While the pricing depends heavily on your needs and scale, we offer fixed-price plans for better predictability for both sides. In our experience, volume-based pricing is a legacy headache we want to avoid.
If you are FDA-regulated and looking for a modern signing solution, we are happy to discuss your requirements in detail. You can write us (hi@documenso.com) or contact [our enterprise team](https://documen.so/21cfr) at any time or stage.
@ -70,4 +61,3 @@ If you have any questions or comments, please reach out on [Twitter / X](https:/
Best from Hamburg\
Timur

View File

@ -0,0 +1,599 @@
---
title: 'Enhancing Document Signing: Introducing 5 New Advanced Fields'
description: "Explore Documenso's new advanced signing fields, including improved text fields, numbers, radio buttons, checkboxes, and dropdowns. Learn about the development challenges we overcame and how these additions provide greater flexibility for document signing."
authorName: 'Catalin Pit'
authorImage: '/blog/blog-author-catalin.webp'
authorRole: 'I like to code and write'
date: 2024-08-09
tags:
- Signing fields
- Development
---
Until recently, Documenso provided a set of 5 fields for document signing: signature, email, name, date, and a text field for additional information. While these fields covered the basic requirements for document signing, we recognized the need for more flexibility and variety.
As a result, we've decided to introduce several additional fields, such as:
- _(an improved)_ Text field
- Number field
- Radio field
- Checkbox field
- Dropdown/Select field
These new fields bring more flexibility and variety to Documenso. As the document owner, they allow you to gather more specific or extra information from the signers.
## New Fields Introduction
Let's take a closer look at each new field type.
### Text Field
While the text field was previously available, it could not be configured. It was a simple input box where signers could enter a single line of text.
The image illustrates the old text field in the document editor.
![Old text signing field in the Documenso document editor](/blog/advanced-fields/old-text-field.jpeg)
The revamped text field now offers a range of configuration options, allowing you to:
- Add a label, placeholder, default text, and character limit
- Set the field as required or read-only
![The advanced settings tab for the text field in the Documenso document editor](/blog/advanced-fields/text-field-advanced-settings.webp)
On the signing side, the field remained mostly the same visually. The only thing that changed is the functionality, which needs to take into consideration the validation rules. For example, if the field is required, the signer must enter a value to sign it. Or, if the field has a character limit, the value entered by the signer shouldn't exceed the limit.
The image below illustrates four different text fields with various configurations.
![The text signing field on the Documenso signing page](/blog/advanced-fields/text-field-signing.webp)
The first text field has no default value ("Add text") or configuration. You can sign the field by entering any text.
![The first text field input](/blog/advanced-fields/first-text-field-input.webp)
The second text field, "label-1"/"text-1", has the following configurations:
- Label
- Placeholder
- Default text
- Character limit
Since there is a default value, the field auto-signs with that value. However, you can re-sign the field with a new value that doesn't exceed the character limit.
![The second text field input](/blog/advanced-fields/second-text-field-input.webp)
The third field, "label-2"/"text-2", has the same configurations as the second one, with one addition - the `required` option is checked. When the field is marked as `required`, you must sign it before completing the document.
Apart from that, it works like the second field.
![The third text field input](/blog/advanced-fields/third-text-field-input.webp)
The fourth field, "label-3"/"text-3", has the same configurations as the second one, with one addition—`read-only` is checked. That means the field auto-signs with the default value, and you cannot modify it.
#### Unsigned Fields
You can unsign a field to change the value and sign it again. The unsigned state of the field varies depending on its configuration:
- If the field has a label, it displays it instead of "Add text" when unsigned.
- If the field has a default value, the default value will be shown when unsigned.
- If the field has both a label and a default value, the label will take precedence and be displayed when unsigned.
The image below shows the unsigned state of the text fields.
![A screenshot illustrating the various text fields unsigned](/blog/advanced-fields/unsigned-text-fields.webp)
The only exception is the fourth, read-only field, which cannot be unsigned or modified.
### Number Field
We also introduced a new "Number" field for inserting and signing documents with numeric values. This field helps collect quantities, measurements, and other data best represented as numbers.
![The advanced settings tab for the number field on the Documenso document editor page](/blog/advanced-fields/number-field-advanced-settings.webp)
The "Number" field offers a range of configuration options, which allows you to:
- Set a label, placeholder and default value
- Specify the number format
- Mark the field as _required_ or _read-only_
- Specify minimum and maximum values
The Number field looks and works similarly to the Text field. The difference is that it accepts only numeric values and has 2 additional configurations: the number format and the minimum and maximum values.
### Radio Field
Radio buttons allow signers to select a single option from a pre-defined list the document owner sets.
Before sending the document for signing, you must add at least one radio option, which can contain a string or an empty value and can be checked or unchecked. However, it's important to note that only one option can be checked at a time.
When it comes to field configuration, you can mark the field as _required_ or _read-only_.
![The advanced settings tab for the radio field in the Documenso document editor](/blog/advanced-fields/radio-field-advanced-settings.webp)
The image below shows what the signer sees after the document is sent for signing.
![The radio signing field on the Documenso signing page](/blog/advanced-fields/radio-field-sign-page.webp)
Note: The image is modified to display both the unsigned and signed states of the field.
Since the field has a preselected option (option `radio-val-2-checked`), it will automatically sign with that value and appear like the field marked with the number 1.
If the field is not read-only, the signer can:
- Unsign the field and choose another option by clicking on it.
- Re-sign with the default value by refreshing the page when the field is unsigned.
However, if the field is marked as read-only, the signer cannot modify the preselected value.
### Dropdown/Select Field
We have also introduced a new "Dropdown/Select" field that allows signers to pick an option from a pre-defined list of choices. This field type is ideal for scenarios with limited valid options, such as selecting a country, state, or category.
When setting up a "Dropdown/Select" field, you can:
- Add multiple options
- Mark the field as _required_ or _read-only_
- Pick a default option from the list of choices
![The advanced settings tab for the select field in the Documenso document editor](/blog/advanced-fields/select-field-advanced-settings.webp)
On the signing page, the "Dropdown/Select" field appears as shown below:
![The select field on the Documenso document signing page](/blog/advanced-fields/select-field-sign-page.webp)
Here's how the "Dropdown/Select" field works:
- If no default value is set, the field will not auto-sign. The signer must click on the field and select an option from the dropdown list to sign it.
- After signing, the field displays the selected value, similar to a signed text field.
- If the field is marked as required, signers must select a value before completing the signing process.
- If the field is marked as read-only, signers can view the selected value but cannot modify it.
### Checkbox Field
The last field introduced is the "Checkbox" field, which allows signers to select multiple options from a pre-defined list. This field is helpful for scenarios where signers need to choose multiple items or agree to several terms and conditions, for example.
Before sending the document for signing, you must add at least one checkbox option. This option can contain a string or an empty value and can be checked or unchecked. Unlike the "Radio" field, the "Checkbox" field can have multiple checked options.
Like other fields, you can mark the "Checkbox" as _required_ or _read-only_. In addition to that, it also has a validation field, and you can specify how many checkboxes the signer should sign:
- Select at least X _(a number from 1 to 10)_
- Select at most X _(a number from 1 to 10)_
- Select exactly X _(a number from 1 to 10)_
![The advanced settings tab for the checkbox field in the Documenso document editor](/blog/advanced-fields/checkbox-field-advanced-settings.webp)
When a signer receives the document, they will see the "Checkbox" field as shown below:
![The checkbox field on the Documenso document signing page](/blog/advanced-fields/checkbox-sign-page.webp)
The image illustrates both field states - signed and un-signed. In this example, the 'Checkbox' field has two options checked by default, so it auto-signs.
The field marked '1' appears when the signer visits the page for the first time or when the user refreshes the page and no option is selected. The field marked '2' displays the cleared state, where all choices have been deselected. This shows how the field looks when a user clears all selections.
In this example, no validation rule has been set, allowing the signer to select any options. However, when a validation rule is applied, signers must meet the specified criteria to complete the signing process.
## Development Challenges
The introduction of these new fields wasn't without its challenges. The main challenges were:
- Deciding how to store the new information for the fields in the database
- Differentiation of recipients using colours
- Storing the advanced settings for the local fields on the frontend
- Implementing the Checkbox and Radio fields
### 1st Challenge: Store New Field Information
The first challenge was deciding how to store the extra information for each new field in the database. Each field has unique properties, with only `required` and `read-only` shared by all the advanced fields.
The existing `Field` model in the database looks like this:
```js
model Field {
id Int @id @default(autoincrement())
secondaryId String @unique @default(cuid())
documentId Int?
templateId Int?
recipientId Int
type FieldType
page Int
positionX Decimal @default(0)
positionY Decimal @default(0)
width Decimal @default(-1)
height Decimal @default(-1)
customText String
inserted Boolean
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Signature Signature?
@@index([documentId])
@@index([templateId])
@@index([recipientId])
}
```
Initially, we considered creating a new `FieldMeta` table with columns for each field property. However, this approach has 2 issues.
First, the advanced fields only share two common properties: `required` and `read-only`. Since all the other properties are unique to each field type, this would result in many nullable columns in the `FieldMeta` model.
Secondly, creating a new database table with columns for each field property and the associated relationships would increase the database complexity.
As a result, we decided to look for another solution that would better work with our use case.
### Solution: JSONB Field
Since the advanced settings data is unique to each field, we decided to store it as JSON using PostgreSQL's `JSONB` data type. We added a new optional `fieldMeta` property of type `JSONB` to the Field model:
```js
model Field {
id Int @id @default(autoincrement())
secondaryId String @unique @default(cuid())
documentId Int?
templateId Int?
recipientId Int
type FieldType
page Int
positionX Decimal @default(0)
positionY Decimal @default(0)
width Decimal @default(-1)
height Decimal @default(-1)
customText String
inserted Boolean
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Signature Signature?
fieldMeta Json? <<<<<----- added this
@@index([documentId])
@@index([templateId])
@@index([recipientId])
}
```
This approach allows us to store each field's settings as a JSON object. We use Zod schemas to parse and validate the field metadata when reading from or writing to the database to ensure data integrity.
This approach has several benefits:
- **Consistency**: The application uses the same Zod schema to retrieve and insert data into the database. That means the data is consistent throughout the app.
- **Type safety**: By parsing the data with Zod, we can guarantee that the data matches the expected types and structure. We can also use Zod's `infer` utility to enable strong typing and autocompletion.
- **Better error handling**: Zod provides thorough error messages indicating which part of the data is invalid. That makes it easier & faster to debug and fix issues.
- **Maintainability**: Reusing the same Zod schema for retrieving and inserting data into the database makes the data structure easier to maintain.
However, using `JSONB` also has drawbacks like data querying. Since the data is stored as JSON (more specifically, in binary format), complex queries can be less efficient compared to querying normalized relational data. On top of that, querying data requires specific operators and functions, such as `->`, `->>`, `@>`, and `?`. This makes the querying more verbose and less intuitive, and hence, it requires more finesse.
Another drawback is the storage overhead. `JSONB` data is stored in a binary format, which can result in some storage overhead compared to normalized relational data. In cases where the JSON data is large or contains a lot of redundant information, the storage overhead can be significant.
Despite these drawbacks, the `JSONB` type suits our use case, as the field meta information is relatively small and doesn't require complex querying. The flexibility of `JSONB` matches the dynamic nature of the fieldMeta field.
> Postgres provides 2 fields for storing JSON data — `json` and `jsonb`. For more information, you can [check out the documentation](https://www.postgresql.org/docs/current/datatype-json.html).
### 2nd Challenge: Storing Fields' Advanced Settings on Frontend
The next challenge was finding the best way to store the advanced field settings entered by users.
Currently, the app only saves the fields and associated settings to the database when the user moves to the next step.
![Documenso advanced field signing](/blog/advanced-fields/documenso-advanced-fields-signing.webp)
The fields are stored locally until the user proceeds to the next step. This means all fields and their settings are lost when the user:
- Closes the advanced settings tab
- Refreshes the page
- Closes the tab
- Navigates to the previous step
In the future, we plan to improve this flow and save the fields on blur, preserving user data even if they navigate away. However, until then, we needed a solution to save the advanced settings when the user closes the settings tab.
### Solution: Local Storage
Our temporary solution is to store the advanced settings in local storage, as the fields are only available locally. If the fields were saved in the database, we could store the advanced settings alongside them.
![Documenso field advanced settings](/blog/advanced-fields/documenso-field-advanced-settings.webp)
Since the fields are not saved in the database, we must persist the data until the user moves to the next step, at which point the data is saved to the database. Storing the data in local storage allows users to open, close, and configure various fields in the advanced settings tab without losing information.
When the user proceeds to the next step, the fields and their advanced settings are saved into the database, and the local storage is cleared.
We also recognized the dangers of saving data to local storage, as users could modify it and break the application. As a result, we have implemented extensive checks on both the backend and frontend, in addition to parsing and validating data with Zod.
However, this solution has limitations. The data is still lost when the user:
- Refreshes the page
- Navigates to the previous step
- Closes the browser
In these cases, the fields are wiped from the document. A future improvement to save fields to the database on blur will solve this issue.
### 3rd Challenge: Radio and Checkbox Fields
Implementing the Radio and Checkbox fields was challenging from both logical and design perspectives. Both fields can contain empty and non-empty values, and the Checkbox field allows users to select multiple empty/non-empty values.
![The radio and checkbox signing fields on the Documenso document signing page](/blog/advanced-fields/radio-and-checkbox-fields.webp)
The image above shows the Radio and Checkbox fields in the document editor. The Radio field on the left-hand side has 4 options, 1 of which is checked. The Checkbox field on the right-hand side has 4 options, 2 of which are checked.
The Radio field was easier to implement because users can only select one option, resulting in simpler logic. The signer clicks on an option to choose it, and the field auto-signs with that value. To change the selection, the user clicks another option, un-signing the field and re-signing it with the new value.
The Checkbox field was more challenging because:
- Signers can select multiple options simultaneously, resulting in the field containing multiple values.
- It can have validation rules (e.g., selecting at least, at most, or exactly X options).
- Users can check/uncheck options by clicking them or clear the field with a button.
These factors make the Checkbox field more complex and challenging to implement correctly.
### Solution
Instead of focusing on a specific solution, we'll discuss the general implementation and its most challenging aspects. I'll include a link to the complete implementation for each field so you can check it out.
**Radio Field**
The way signing works for the Radio field is to pull the data from the database and display the available options. If the field has a default value set by the document sender, it auto-signs with that value.
```ts
...
const values = parsedFieldMeta.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
...
const shouldAutoSignField =
(!field.inserted && selectedOption) ||
(!field.inserted && defaultValue) ||
(!field.inserted && parsedFieldMeta.readOnly && defaultValue);
...
useEffect(() => {
if (shouldAutoSignField) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, [selectedOption, field]);
```
> You can see the complete implementation of the radio field in the [radio-field.tsx](<https://github.com/documenso/documenso/blob/main/apps/web/src/app/(signing)/sign/%5Btoken%5D/radio-field.tsx>) file.
If the field is not read-only and the user clicks on another option, the field un-signs and re-signs with the new value. Read-only fields cannot be modified.
The value is saved in the database whenever the field is signed, whether by auto-signing or user. Similarly, the value is removed from the database when the field is unsigned.
Since the Radio field can contain empty values, we map over the values and replace the empty ones with a unique string `empty-value-${item.id}`. This is because the empty string is not a valid value for the field, and we need to differentiate between empty and non-empty values.
**Checkbox Field**
The Checkbox field implementation is similar to the Radio field, with the main differences being:
- Checkbox fields can contain multiple values.
- Checkbox fields have validation rules that need to be enforced.
```ts
...
const values = parsedFieldMeta.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const [checkedValues, setCheckedValues] = useState(
values
?.map((item) =>
item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '',
)
.filter(Boolean) || [],
);
...
```
As with the Radio field, we map over the values and replace empty ones with a unique string. We also keep track of the checked values to display the field correctly and validate them against the validation rules.
```ts
...
const values = parsedFieldMeta.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const [checkedValues, setCheckedValues] = useState(
values
?.map((item) =>
item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '',
)
.filter(Boolean) || [],
);
const checkboxValidationRule = parsedFieldMeta.validationRule;
const checkboxValidationLength = parsedFieldMeta.validationLength;
const validationSign = checkboxValidationSigns.find(
(sign) => sign.label === checkboxValidationRule,
);
...
```
Then, we retrieve the validation rule and length from the database and find the corresponding validation sign (e.g., ">=", "=", "\<=") based on the rule label. The `checkboxValidationSigns` array maps rule labels to their corresponding signs.
```ts
export const checkboxValidationSigns = [
{
label: 'Select at least',
value: '>=',
},
{
label: 'Select exactly',
value: '=',
},
{
label: 'Select at most',
value: '<=',
},
];
```
We then check if the length condition is met based on the validation rule, sign, and length. If met, the user can proceed with signing the field. Otherwise, they need to select the correct number of options.
```ts
...
const values = parsedFieldMeta.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const [checkedValues, setCheckedValues] = useState(
values
?.map((item) =>
item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '',
)
.filter(Boolean) || [],
);
const checkboxValidationRule = parsedFieldMeta.validationRule;
const checkboxValidationLength = parsedFieldMeta.validationLength;
const validationSign = checkboxValidationSigns.find(
(sign) => sign.label === checkboxValidationRule,
);
const isLengthConditionMet = useMemo(() => {
if (!validationSign) return true;
return (
(validationSign.value === '>=' && checkedValues.length >= (checkboxValidationLength || 0)) ||
(validationSign.value === '=' && checkedValues.length === (checkboxValidationLength || 0)) ||
(validationSign.value === '<=' && checkedValues.length <= (checkboxValidationLength || 0))
);
}, [checkedValues, validationSign, checkboxValidationLength]);
...
```
In summary, the Checkbox field allows signers to select multiple options, with the field automatically signing based on these selections. Signers can un-sign the field by deselecting options or clearing all selections. The system enforces validation rules throughout this process, ensuring signers select the required number of options to sign the field successfully.
> You can see the complete implementation of the checkbox field in the [checkbox-field.tsx](<https://github.com/documenso/documenso/blob/main/apps/web/src/app/(signing)/sign/%5Btoken%5D/checkbox-field.tsx>) file.
### 4th Challenge: Recipients' Colors
Another challenge we faced was using colours to differentiate recipients. We needed to dynamically generate and reuse the same Tailwind classes across several components. However, TailwindCSS only includes the CSS classes used in the project, discarding unused ones from the final build. This resulted in colours not being applied to the components, as the classes were not used in the code.
The images below illustrate the recipients' colours in 2 different states.
In the first image, the "Signature" field on the right is in the active state (blue), triggered when the user clicks the field to drag it onto the document. The signature field on the left, placed on the document, is in the normal state.
The first image illustrates the "Signature" field in the active state, triggered when the user clicks on it.
![Screenshot illustrating the active state for a field on the Documenso document editor page](/blog/advanced-fields/field-active-state.webp)
The second image shows the "Signature" field in the normal state.
![Screenshot illustrating the fields for a signer on the Documenso document editor page](/blog/advanced-fields/recipient-colours.webp)
The document editor consists of various components (fields, recipients, etc.), meaning the same colours and code are reused across multiple components.
```ts
export const combinedStyles = {
'orange-500': {
ringColor: 'ring-orange-500/30 ring-offset-orange-500',
borderWithHover: 'border-orange-500 hover:border-orange-500',
...,
},
'green-500': {
ringColor: 'ring-green-500/30 ring-offset-green-500',
borderWithHover: 'border-green-500 hover:border-green-500',
...,
},
'blue-500': {
ringColor: 'ring-blue-500/30 ring-offset-blue-500',
borderWithHover: 'border-blue-500 hover:border-blue-500',
...,
'gray-500': {
ringColor: 'ring-gray-500/30 ring-offset-gray-500',
borderWithHover: 'border-gray-500 hover:border-gray-500',
...,
},
...,
};
export const MyComponent = () => {
const selectedSignerStyles = useSelectedSignerStyles(selectedSigner, combinedStyles);
return (
<div
className={cn(
selectedSigner ? selectedSignerStyles.ringClass : selectedSignerStyles.borderClass,
)}
>
<h1>Hello</h1>
</div>
);
};
```
The code above shows a naive solution using a `combinedStyles` object containing TailwindCSS classes for various component styles (ring, border, hover, etc.).
Components would use custom hooks to apply appropriate styles based on the selected recipient. For example, recipient 1 would use `green-500` styles, turning all related elements green.
![Screenshot illustrating the recipient colour on the Documenso document editor page](/blog/advanced-fields/recipient-colour-example.webp)
The problem with this approach is that we can't import the `combinedStyles` object into other components because TailwindCSS will remove the unused classes. That means we had to copy and paste the same object into multiple files. As a result, it pollutes the codebase with duplicated code, which makes it harder to maintain and scale the code. As the application grows, the `combinedStyles` object will become larger and more complex. Moreover, it's not very flexible, as it doesn't allow for easy customization of the colours.
While this approach works, there is a more efficient and scalable solution.
### Solution: Modularise the Logic and Use CSS Variables
To address the challenge of reusing colours across components, we moved the colours and associated hooks to a separate file, defining styles only in this file and accessing them from components through custom hooks.
```ts
export const SIGNER_COLOR_STYLES = {
green: {
default: {
background: 'bg-[hsl(var(--signer-green))]',
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-green)/10%),0_0_0_2px_hsl(var(--signer-green)/60%),0_0_0_0.5px_hsl(var(--signer-green))]',
fieldItem:
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-green))]/10 hover:to-[hsl(var(--signer-green))]/10',
fieldItemInitials:
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-green))]',
comboxBoxItem: 'hover:bg-[hsl(var(--signer-green)/15%)] active:bg-[hsl(var(--signer-green)/15%)]',
},
},
...
};
export type CombinedStylesKey = keyof typeof SIGNER_COLOR_STYLES;
export const AVAILABLE_SIGNER_COLORS = [
'green',
'blue',
'purple',
'orange',
'yellow',
'pink',
] as const satisfies CombinedStylesKey[];
export const useSignerColors = (index: number) => {
const key = AVAILABLE_SIGNER_COLORS[index % AVAILABLE_SIGNER_COLORS.length];
return SIGNER_COLOR_STYLES[key];
};
export const getSignerColorStyles = (index: number) => {
return useSignerColors(index);
};
```
> The file was truncated for readability. You can see the complete code in the [signer-colors.ts](https://github.com/documenso/documenso/blob/main/packages/ui/lib/signer-colors.ts) file from the Documenso repository.
The `SIGNER_COLOR_STYLES` object contains the styles for each colour, such as the background, border, and hover colours. Based on the signer's index, the `useSignerColors` hook gets the styles for a specific colour. The `getSignerColorStyles` function is a helper function that returns the styles for a particular signer.
Now, the components can access the colours and styles using custom hooks. For example, to get the styles for a specific signer, the component can call the `useSignerColors` hook with the signer's index.
```ts
const signerStyles = useSignerColors(recipientIndex);
```
The hook will return the styles for that signer, which can then be applied to the component. For example, you can access the signer's background colour using `signerStyles.default.background`.
This approach makes managing the colours and styles easier, as they are defined in a single file. Changing or adding colours can be done in one place, making the code more modular and reusable.
We also opted for CSS variables to define colours, allowing more flexibility and consistency in styling. A single CSS variable for each colour can cover a wide range of states without relying on multiple TailwindCSS classes. For example, you can easily set the opacity and lightness of colour without using multiple classes. CSS variables help align colours with our brand guidelines while simplifying the overall styling process.
## The End
We're happy to see the new advanced fields released because they offer our users more flexibility, variety, and customization options. Implementing the new fields came with its challenges, but we overcame them and learned from them. We're excited to continue enhancing Documenso and providing our users with the best document signing experience.

View File

@ -8,7 +8,64 @@ Check out what's new in the latest version and read our thoughts on it. For more
---
## v1.6.0: Enhancing Team Collaboration and User Experience (latest)
# Documenso v1.6.1: Internationalization, Enhanced OIDC, and More
We're excited to announce the release of Documenso v1.6.1, which brings several improvements to enhance your document signing experience. Here are the key updates:
## 🌟 Key Features
### New Initials Field Type
We've added a new field type for initials, giving you more options for document customization. This feature allows signers to quickly initial documents, adding an extra layer of verification to your signing process.
### Internationalization Support
We've taken a big step towards making Documenso accessible to a global audience by adding i18n (internationalization) support for our marketing pages and adding translations to support multiple languages.
While this is just a small step towards a fully multilingual Documenso, it's a significant step towards making our platform more accessible to a global audience.
Using our new knowledge and findings from the marketing implementation, we aim to tackle our web application in the near future for a fully global Documenso.
### Enhanced OpenID Connect (OIDC) Integration
For our self-hosted users leveraging OIDC for authentication:
- Now supports OIDC-only signup
- Added trust for email addresses from OIDC providers
- The OIDC sign-in button text is now configurable
## 🔧 Other Improvements
- **UI Enhancements**:
- Fixed display issues with field names/labels in dark mode
- Improved truncation of titles to prevent UI breaks
- **User Experience**:
- The signup option is now shown only to users without existing accounts
- Fixed issues with radio and checkbox fields having empty values
- **API and Security**:
- Fixed a bug in the date format API
- Improved URL parsing for enhanced security
- Added support for dynamic external IDs for direct templates
- **Document Management**:
- Resolved an issue with downloading audit log certificates
We've also made various other minor fixes and improvements to ensure a smoother Documenso experience.
## 👏 Community Contributions
A big thank you to our growing community! This release includes contributions from several new contributors, showcasing the power of open-source collaboration.
We appreciate your continued support and feedback as we work to make Documenso the best document signing solution available. Enjoy the new features and improvements in v1.6.1!
---
## v1.6.0: Enhancing Team Collaboration and User Experience
### <small>Released 23th July 2024</small>

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/marketing",
"version": "1.6.1-rc.0",
"version": "1.7.0-rc.4",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@ -20,8 +20,8 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
"@lingui/macro": "^4.11.1",
"@lingui/react": "^4.11.1",
"@lingui/macro": "^4.11.3",
"@lingui/react": "^4.11.3",
"@openstatus/react": "^0.0.3",
"cmdk": "^0.2.1",
"contentlayer": "^0.3.4",
@ -32,16 +32,16 @@
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "14.0.3",
"next": "14.2.6",
"next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0",
"posthog-js": "^1.77.3",
"react": "18.2.0",
"react": "^18",
"react-confetti": "^6.1.0",
"react-dom": "18.2.0",
"react-dom": "^18",
"react-hook-form": "^7.43.9",
"react-icons": "^4.11.0",
"recharts": "^2.7.2",
@ -50,18 +50,10 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@lingui/loader": "^4.11.1",
"@lingui/swc-plugin": "4.0.6",
"@lingui/loader": "^4.11.3",
"@lingui/swc-plugin": "4.0.8",
"@types/node": "20.1.0",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7"
},
"overrides": {
"next-auth": {
"next": "$next"
},
"next-contentlayer": {
"next": "$next"
}
"@types/react": "^18",
"@types/react-dom": "^18"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -5,6 +5,8 @@ import { allDocuments } from 'contentlayer/generated';
import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
export const dynamic = 'force-dynamic';
export const generateMetadata = ({ params }: { params: { content: string } }) => {
@ -29,6 +31,8 @@ const mdxComponents: MDXComponents = {
* Will render the document if it exists, otherwise will return a 404.
*/
export default function ContentPage({ params }: { params: { content: string } }) {
setupI18nSSR();
const post = allDocuments.find((post) => post._raw.flattenedPath === params.content);
if (!post) {

View File

@ -7,6 +7,8 @@ import { ChevronLeft } from 'lucide-react';
import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { CallToAction } from '~/components/(marketing)/call-to-action';
export const dynamic = 'force-dynamic';
@ -47,6 +49,8 @@ const mdxComponents: MDXComponents = {
};
export default function BlogPostPage({ params }: { params: { post: string } }) {
setupI18nSSR();
const post = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
if (!post) {

View File

@ -42,7 +42,7 @@ export default function BlogPage() {
>
<div className="flex items-center gap-x-4 text-xs">
<time dateTime={post.date} className="text-muted-foreground">
<Trans>{i18n.date(new Date(), { dateStyle: 'short' })}</Trans>
<Trans>{i18n.date(new Date(post.date), { dateStyle: 'short' })}</Trans>
</time>
{post.tags.length > 0 && (

View File

@ -44,6 +44,10 @@ export default async function OSSFriendsPage() {
src={backgroundPattern}
alt="background pattern"
className="-mr-[15vw] -mt-[15vh] h-full max-h-[150vh] scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:-mr-[50vw] md:scale-150 lg:scale-[175%]"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
}}
/>
</div>
</div>

View File

@ -5,6 +5,8 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64';
@ -46,8 +48,8 @@ export const SinglePlayerClient = () => {
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
fields: {
title: 'Add document',
description: 'Upload a document and add fields.',
title: msg`Add document`,
description: msg`Upload a document and add fields.`,
stepIndex: 1,
onBackStep: uploadedFile
? () => {
@ -58,8 +60,8 @@ export const SinglePlayerClient = () => {
onNextStep: () => setStep('sign'),
},
sign: {
title: 'Sign',
description: 'Enter your details.',
title: msg`Sign`,
description: msg`Enter your details.`,
stepIndex: 2,
onBackStep: () => setStep('fields'),
},

View File

@ -1,5 +1,7 @@
import type { Metadata } from 'next';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { SinglePlayerClient } from './client';
export const metadata: Metadata = {
@ -13,5 +15,7 @@ export const dynamic = 'force-dynamic';
// !: the Single Player Mode page. This regression was introduced during
// !: the upgrade of Next.js to v13.5.x.
export default function SingleplayerPage() {
setupI18nSSR();
return <SinglePlayerClient />;
}

View File

@ -26,6 +26,10 @@ export default function NotFound() {
src={backgroundPattern}
alt="background pattern"
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-100 lg:scale-[100%]"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
}}
priority
/>
</motion.div>

View File

@ -2,11 +2,14 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { AutoplayType } from 'embla-carousel-autoplay';
import Autoplay from 'embla-carousel-autoplay';
import useEmblaCarousel from 'embla-carousel-react';
import { usePlausible } from 'next-plausible';
import { useTheme } from 'next-themes';
import { Card } from '@documenso/ui/primitives/card';
@ -61,6 +64,7 @@ const SLIDES = [
export const Carousel = () => {
const { _ } = useLingui();
const event = usePlausible();
const slides = SLIDES;
const [_isPlaying, setIsPlaying] = useState(false);
@ -238,7 +242,10 @@ export const Carousel = () => {
if (!mounted) return null;
return (
<>
<Card className="mx-auto mt-12 w-full max-w-4xl rounded-2xl p-1 before:rounded-2xl" gradient>
<Card
className="relative mx-auto mt-12 w-full max-w-4xl rounded-2xl p-1 before:rounded-2xl"
gradient
>
<div className="overflow-hidden rounded-xl" ref={emblaRef}>
<div className="flex touch-pan-y rounded-xl">
{slides.map((slide, index) => (
@ -269,6 +276,19 @@ export const Carousel = () => {
</span>
<Progress value={progress} className="h-1" />
</div>
<Link
href="https://documen.so/book-a-demo"
className="bg-foreground/70 dark:bg-foreground/80 absolute inset-0 hidden flex-col items-center justify-center gap-y-2 rounded-xl opacity-0 backdrop-blur-[2px] transition-opacity group-hover:opacity-100 md:flex"
onClick={() => event('view-demo')}
>
<span className="text-background max-w-[60ch] text-2xl font-semibold">Book a Demo</span>
<span className="text-background max-w-[60ch] text-center text-sm">
Want to learn more about Documenso and how it works? Book a demo today! Our founders
will walk you through the application and answer any questions you may have regarding
usage, integration, and more.
</span>
</Link>
</Card>
<div className="mx-auto mt-6 w-full max-w-4xl px-2 sm:mt-12">

View File

@ -24,6 +24,10 @@ export const FasterSmarterBeautifulBento = ({
src={backgroundPattern}
alt="background pattern"
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
}}
/>
</div>
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">

View File

@ -86,6 +86,10 @@ export const Hero = ({ className, ...props }: HeroProps) => {
src={backgroundPattern}
alt="background pattern"
className="-mr-[50vw] -mt-[15vh] h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</motion.div>
</div>

View File

@ -21,6 +21,10 @@ export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplat
src={backgroundPattern}
alt="background pattern"
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</div>
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">

View File

@ -25,6 +25,10 @@ export const ShareConnectPaidWidgetBento = ({
src={backgroundPattern}
alt="background pattern"
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</div>
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
@ -39,7 +43,7 @@ export const ShareConnectPaidWidgetBento = ({
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="text-foreground/80 leading-relaxed">
<strong className="block">
<Trans>Easy Sharing (Soon).</Trans>
<Trans>Easy Sharing.</Trans>
</strong>
<Trans>Receive your personal link to share with everyone you care about.</Trans>
</p>

View File

@ -8,7 +8,7 @@ const config: LinguiConfig = {
locales: APP_I18N_OPTIONS.supportedLangs as unknown as string[],
catalogs: [
{
path: '<rootDir>/../../packages/lib/translations/web/{locale}',
path: '<rootDir>/../../packages/lib/translations/{locale}/web',
include: ['<rootDir>/apps/web/src'],
},
{

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.6.1-rc.0",
"version": "1.7.0-rc.4",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@ -23,8 +23,8 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
"@lingui/macro": "^4.11.1",
"@lingui/react": "^4.11.1",
"@lingui/macro": "^4.11.3",
"@lingui/react": "^4.11.3",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"@tanstack/react-query": "^4.29.5",
@ -35,7 +35,7 @@
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "14.0.3",
"next": "14.2.6",
"next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-plausible": "^3.10.1",
@ -44,8 +44,9 @@
"perfect-freehand": "^1.2.0",
"posthog-js": "^1.75.3",
"posthog-node": "^3.1.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react": "^18",
"react-call": "^1.3.0",
"react-dom": "^18",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.43.9",
"react-hotkeys-hook": "^4.4.1",
@ -60,25 +61,17 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@lingui/loader": "^4.11.1",
"@lingui/swc-plugin": "4.0.6",
"@documenso/tailwind-config": "*",
"@lingui/loader": "^4.11.3",
"@lingui/swc-plugin": "4.0.8",
"@simplewebauthn/types": "^9.0.1",
"@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/papaparse": "^5.3.14",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/ua-parser-js": "^0.7.39",
"typescript": "5.2.2"
},
"overrides": {
"next-auth": {
"next": "$next"
},
"next-contentlayer": {
"next": "$next"
}
}
}

View File

@ -2,6 +2,9 @@
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Recipient } from '@documenso/prisma/client';
import { type Document, SigningStatus } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@ -22,20 +25,21 @@ export type AdminActionsProps = {
};
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
trpc.admin.resealDocument.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Document resealed',
title: _(msg`Success`),
description: _(msg`Document resealed`),
});
},
onError: () => {
toast({
title: 'Error',
description: 'Failed to reseal document',
title: _(msg`Error`),
description: _(msg`Failed to reseal document`),
variant: 'destructive',
});
},
@ -54,19 +58,23 @@ export const AdminActions = ({ className, document, recipients }: AdminActionsPr
)}
onClick={() => resealDocument({ id: document.id })}
>
Reseal document
<Trans>Reseal document</Trans>
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[40ch]">
Attempts sealing the document again, useful for after a code change has occurred to
resolve an erroneous document.
<Trans>
Attempts sealing the document again, useful for after a code change has occurred to
resolve an erroneous document.
</Trans>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="outline" asChild>
<Link href={`/admin/users/${document.userId}`}>Go to owner</Link>
<Link href={`/admin/users/${document.userId}`}>
<Trans>Go to owner</Trans>
</Link>
</Button>
</div>
);

View File

@ -1,5 +1,7 @@
import { Trans } from '@lingui/macro';
import { DateTime } from 'luxon';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import {
Accordion,
@ -23,6 +25,8 @@ type AdminDocumentDetailsPageProps = {
};
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
setupI18nSSR();
const document = await getEntireDocument({ id: Number(params.id) });
return (
@ -35,28 +39,34 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
{document.deletedAt && (
<Badge size="large" variant="destructive">
Deleted
<Trans>Deleted</Trans>
</Badge>
)}
</div>
<div className="text-muted-foreground mt-4 text-sm">
<div>
Created on: <LocaleDate date={document.createdAt} format={DateTime.DATETIME_MED} />
<Trans>Created on</Trans>:{' '}
<LocaleDate date={document.createdAt} format={DateTime.DATETIME_MED} />
</div>
<div>
Last updated at: <LocaleDate date={document.updatedAt} format={DateTime.DATETIME_MED} />
<Trans>Last updated at</Trans>:{' '}
<LocaleDate date={document.updatedAt} format={DateTime.DATETIME_MED} />
</div>
</div>
<hr className="my-4" />
<h2 className="text-lg font-semibold">Admin Actions</h2>
<h2 className="text-lg font-semibold">
<Trans>Admin Actions</Trans>
</h2>
<AdminActions className="mt-2" document={document} recipients={document.Recipient} />
<hr className="my-4" />
<h2 className="text-lg font-semibold">Recipients</h2>
<h2 className="text-lg font-semibold">
<Trans>Recipients</Trans>
</h2>
<div className="mt-4">
<Accordion type="multiple" className="space-y-4">

View File

@ -1,7 +1,11 @@
'use client';
import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@ -13,6 +17,7 @@ import {
} from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import {
Form,
@ -43,7 +48,9 @@ export type RecipientItemProps = {
};
export const RecipientItem = ({ recipient }: RecipientItemProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const form = useForm<TAdminUpdateRecipientFormSchema>({
@ -55,6 +62,50 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
const columns = useMemo(() => {
return [
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
{
header: _(msg`Type`),
accessorKey: 'type',
cell: ({ row }) => <div>{row.original.type}</div>,
},
{
header: _(msg`Inserted`),
accessorKey: 'inserted',
cell: ({ row }) => <div>{row.original.inserted ? 'True' : 'False'}</div>,
},
{
header: _(msg`Value`),
accessorKey: 'customText',
cell: ({ row }) => <div>{row.original.customText}</div>,
},
{
header: _(msg`Signature`),
accessorKey: 'signature',
cell: ({ row }) => (
<div>
{row.original.Signature?.typedSignature && (
<span>{row.original.Signature.typedSignature}</span>
)}
{row.original.Signature?.signatureImageAsBase64 && (
<img
src={row.original.Signature.signatureImageAsBase64}
alt="Signature"
className="h-12 w-full dark:invert"
/>
)}
</div>
),
},
] satisfies DataTableColumnDef<(typeof recipient)['Field'][number]>[];
}, []);
const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => {
try {
await updateRecipient({
@ -64,14 +115,14 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
});
toast({
title: 'Recipient updated',
description: 'The recipient has been updated successfully',
title: _(msg`Recipient updated`),
description: _(msg`The recipient has been updated successfully`),
});
router.refresh();
} catch (error) {
toast({
title: 'Failed to update recipient',
title: _(msg`Failed to update recipient`),
description: error.message,
variant: 'destructive',
});
@ -93,7 +144,9 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Name</FormLabel>
<FormLabel required>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
@ -109,7 +162,9 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Email</FormLabel>
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
@ -122,7 +177,7 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
<div>
<Button type="submit" loading={form.formState.isSubmitting}>
Update Recipient
<Trans>Update Recipient</Trans>
</Button>
</div>
</fieldset>
@ -131,52 +186,11 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
<hr className="my-4" />
<h2 className="mb-4 text-lg font-semibold">Fields</h2>
<h2 className="mb-4 text-lg font-semibold">
<Trans>Fields</Trans>
</h2>
<DataTable
data={recipient.Field}
columns={[
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
{
header: 'Type',
accessorKey: 'type',
cell: ({ row }) => <div>{row.original.type}</div>,
},
{
header: 'Inserted',
accessorKey: 'inserted',
cell: ({ row }) => <div>{row.original.inserted ? 'True' : 'False'}</div>,
},
{
header: 'Value',
accessorKey: 'customText',
cell: ({ row }) => <div>{row.original.customText}</div>,
},
{
header: 'Signature',
accessorKey: 'signature',
cell: ({ row }) => (
<div>
{row.original.Signature?.typedSignature && (
<span>{row.original.Signature.typedSignature}</span>
)}
{row.original.Signature?.signatureImageAsBase64 && (
<img
src={row.original.Signature.signatureImageAsBase64}
alt="Signature"
className="h-12 w-full dark:invert"
/>
)}
</div>
),
},
]}
/>
<DataTable columns={columns} data={recipient.Field} />
</div>
);
};

View File

@ -4,6 +4,9 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Document } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
@ -26,7 +29,9 @@ export type SuperDeleteDocumentDialogProps = {
};
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const [reason, setReason] = useState('');
@ -43,7 +48,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
await deleteDocument({ id: document.id, reason });
toast({
title: 'Document deleted',
title: _(msg`Document deleted`),
description: 'The Document has been deleted successfully.',
duration: 5000,
});
@ -52,13 +57,13 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
title: _(msg`An error occurred`),
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description:
err.message ??
@ -76,31 +81,41 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
variant="neutral"
>
<div>
<AlertTitle>Delete Document</AlertTitle>
<AlertTitle>
<Trans>Delete Document</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
Delete the document. This action is irreversible so proceed with caution.
<Trans>
Delete the document. This action is irreversible so proceed with caution.
</Trans>
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Document</Button>
<Button variant="destructive">
<Trans>Delete Document</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>Delete Document</DialogTitle>
<DialogTitle>
<Trans>Delete Document</Trans>
</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
This action is not reversible. Please be certain.
<Trans>This action is not reversible. Please be certain.</Trans>
</AlertDescription>
</Alert>
</DialogHeader>
<div>
<DialogDescription>To confirm, please enter the reason</DialogDescription>
<DialogDescription>
<Trans>To confirm, please enter the reason</Trans>
</DialogDescription>
<Input
className="mt-2"
@ -117,7 +132,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
variant="destructive"
disabled={!reason}
>
{isDeletingDocument ? 'Deleting document...' : 'Delete Document'}
<Trans>Delete document</Trans>
</Button>
</DialogFooter>
</DialogContent>

View File

@ -1,10 +1,12 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
@ -12,6 +14,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input';
@ -23,6 +26,8 @@ import { LocaleDate } from '~/components/formatter/locale-date';
// export type AdminDocumentResultsProps = {};
export const AdminDocumentResults = () => {
const { _ } = useLingui();
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
@ -45,6 +50,83 @@ export const AdminDocumentResults = () => {
},
);
const results = findDocumentsData ?? {
data: [],
perPage: 20,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: _(msg`Title`),
accessorKey: 'title',
cell: ({ row }) => {
return (
<Link
href={`/admin/documents/${row.original.id}`}
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
>
{row.original.title}
</Link>
);
},
},
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
},
{
header: _(msg`Owner`),
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.User.name
? extractInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
</Link>
</TooltipTrigger>
<TooltipContent className="flex max-w-xs items-center gap-2">
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
<div className="text-muted-foreground flex flex-col text-sm">
<span>{row.original.User.name}</span>
<span>{row.original.User.email}</span>
</div>
</TooltipContent>
</Tooltip>
);
},
},
{
header: 'Last updated',
accessorKey: 'updatedAt',
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
const onPaginationChange = (newPage: number, newPerPage: number) => {
updateSearchParams({
page: newPage,
@ -56,84 +138,18 @@ export const AdminDocumentResults = () => {
<div>
<Input
type="search"
placeholder="Search by document title"
placeholder={_(msg`Search by document title`)}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
<div className="relative mt-4">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
accessorKey: 'title',
cell: ({ row }) => {
return (
<Link
href={`/admin/documents/${row.original.id}`}
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
>
{row.original.title}
</Link>
);
},
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
},
{
header: 'Owner',
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.User.name
? extractInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
</Link>
</TooltipTrigger>
<TooltipContent className="flex max-w-xs items-center gap-2">
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
<div className="text-muted-foreground flex flex-col text-sm">
<span>{row.original.User.name}</span>
<span>{row.original.User.email}</span>
</div>
</TooltipContent>
</Tooltip>
);
},
},
{
header: 'Last updated',
accessorKey: 'updatedAt',
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
},
]}
data={findDocumentsData?.data ?? []}
perPage={findDocumentsData?.perPage ?? 20}
currentPage={findDocumentsData?.currentPage ?? 1}
totalPages={findDocumentsData?.totalPages ?? 1}
columns={columns}
data={results.data}
perPage={results.perPage ?? 20}
currentPage={results.currentPage ?? 1}
totalPages={results.totalPages ?? 1}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}

View File

@ -1,9 +1,17 @@
import { Trans } from '@lingui/macro';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { AdminDocumentResults } from './document-results';
export default function AdminDocumentsPage() {
setupI18nSSR();
return (
<div>
<h2 className="text-4xl font-semibold">Manage documents</h2>
<h2 className="text-4xl font-semibold">
<Trans>Manage documents</Trans>
</h2>
<div className="mt-8">
<AdminDocumentResults />

View File

@ -2,6 +2,7 @@ import React from 'react';
import { redirect } from 'next/navigation';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
@ -12,6 +13,8 @@ export type AdminSectionLayoutProps = {
};
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
setupI18nSSR();
const { user } = await getRequiredServerComponentSession();
if (!isAdmin(user)) {

View File

@ -5,6 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { BarChart3, FileStack, Settings, Users, Wallet2 } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
@ -33,7 +34,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
>
<Link href="/admin/stats">
<BarChart3 className="mr-2 h-5 w-5" />
Stats
<Trans>Stats</Trans>
</Link>
</Button>
@ -47,7 +48,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
>
<Link href="/admin/users">
<Users className="mr-2 h-5 w-5" />
Users
<Trans>Users</Trans>
</Link>
</Button>
@ -61,7 +62,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
>
<Link href="/admin/documents">
<FileStack className="mr-2 h-5 w-5" />
Documents
<Trans>Documents</Trans>
</Link>
</Button>
@ -75,7 +76,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
>
<Link href="/admin/subscriptions">
<Wallet2 className="mr-2 h-5 w-5" />
Subscriptions
<Trans>Subscriptions</Trans>
</Link>
</Button>
@ -89,7 +90,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
>
<Link href="/admin/site-settings">
<Settings className="mr-2 h-5 w-5" />
Site Settings
<Trans>Site Settings</Trans>
</Link>
</Button>
</div>

View File

@ -3,6 +3,8 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
@ -37,8 +39,10 @@ export type BannerFormProps = {
};
export function BannerForm({ banner }: BannerFormProps) {
const router = useRouter();
const { toast } = useToast();
const { _ } = useLingui();
const router = useRouter();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
@ -67,8 +71,8 @@ export function BannerForm({ banner }: BannerFormProps) {
});
toast({
title: 'Banner Updated',
description: 'Your banner has been updated successfully.',
title: _(msg`Banner Updated`),
description: _(msg`Your banner has been updated successfully.`),
duration: 5000,
});
@ -76,16 +80,17 @@ export function BannerForm({ banner }: BannerFormProps) {
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
title: _(msg`An error occurred`),
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description:
'We encountered an unknown error while attempting to update the banner. Please try again later.',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
}
@ -93,10 +98,14 @@ export function BannerForm({ banner }: BannerFormProps) {
return (
<div>
<h2 className="font-semibold">Site Banner</h2>
<h2 className="font-semibold">
<Trans>Site Banner</Trans>
</h2>
<p className="text-muted-foreground mt-2 text-sm">
The site banner is a message that is shown at the top of the site. It can be used to display
important information to your users.
<Trans>
The site banner is a message that is shown at the top of the site. It can be used to
display important information to your users.
</Trans>
</p>
<Form {...form}>
@ -110,7 +119,9 @@ export function BannerForm({ banner }: BannerFormProps) {
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Enabled</FormLabel>
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
@ -131,7 +142,9 @@ export function BannerForm({ banner }: BannerFormProps) {
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>Background Color</FormLabel>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<FormControl>
<div>
@ -149,7 +162,9 @@ export function BannerForm({ banner }: BannerFormProps) {
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>Text Color</FormLabel>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<FormControl>
<div>
@ -170,14 +185,16 @@ export function BannerForm({ banner }: BannerFormProps) {
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>Content</FormLabel>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
The content to show in the banner, HTML is allowed
<Trans>The content to show in the banner, HTML is allowed</Trans>
</FormDescription>
<FormMessage />
@ -191,7 +208,7 @@ export function BannerForm({ banner }: BannerFormProps) {
loading={isUpdateSiteSettingLoading}
className="mt-4 justify-end self-end"
>
Update Banner
<Trans>Update Banner</Trans>
</Button>
</form>
</Form>

View File

@ -1,3 +1,7 @@
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
@ -8,13 +12,20 @@ import { BannerForm } from './banner-form';
// import { BannerForm } from './banner-form';
export default async function AdminBannerPage() {
setupI18nSSR();
const { _ } = useLingui();
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
return (
<div>
<SettingsHeader title="Site Settings" subtitle="Manage your site settings here" />
<SettingsHeader
title={_(msg`Site Settings`)}
subtitle={_(msg`Manage your site settings here`)}
/>
<div className="mt-8">
<BannerForm banner={banner} />

View File

@ -1,3 +1,5 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
File,
FileCheck,
@ -12,6 +14,7 @@ import {
Users,
} from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
import {
@ -27,6 +30,10 @@ import { SignerConversionChart } from './signer-conversion-chart';
import { UserWithDocumentChart } from './user-with-document';
export default async function AdminStatsPage() {
setupI18nSSR();
const { _ } = useLingui();
const [
usersCount,
usersWithSubscriptionsCount,
@ -49,64 +56,98 @@ export default async function AdminStatsPage() {
return (
<div>
<h2 className="text-4xl font-semibold">Instance Stats</h2>
<h2 className="text-4xl font-semibold">
<Trans>Instance Stats</Trans>
</h2>
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<CardMetric icon={Users} title="Total Users" value={usersCount} />
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
<CardMetric icon={Users} title={_(msg`Total Users`)} value={usersCount} />
<CardMetric icon={File} title={_(msg`Total Documents`)} value={docStats.ALL} />
<CardMetric
icon={UserPlus}
title="Active Subscriptions"
title={_(msg`Active Subscriptions`)}
value={usersWithSubscriptionsCount}
/>
<CardMetric icon={FileCog} title="App Version" value={`v${process.env.APP_VERSION}`} />
<CardMetric
icon={FileCog}
title={_(msg`App Version`)}
value={`v${process.env.APP_VERSION}`}
/>
</div>
<div className="mt-16 gap-8">
<div>
<h3 className="text-3xl font-semibold">Document metrics</h3>
<h3 className="text-3xl font-semibold">
<Trans>Document metrics</Trans>
</h3>
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
<CardMetric icon={FileEdit} title="Drafted Documents" value={docStats.DRAFT} />
<CardMetric icon={FileClock} title="Pending Documents" value={docStats.PENDING} />
<CardMetric icon={FileCheck} title="Completed Documents" value={docStats.COMPLETED} />
<CardMetric icon={FileEdit} title={_(msg`Drafted Documents`)} value={docStats.DRAFT} />
<CardMetric
icon={FileClock}
title={_(msg`Pending Documents`)}
value={docStats.PENDING}
/>
<CardMetric
icon={FileCheck}
title={_(msg`Completed Documents`)}
value={docStats.COMPLETED}
/>
</div>
</div>
<div>
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
<h3 className="text-3xl font-semibold">
<Trans>Recipients metrics</Trans>
</h3>
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
<CardMetric
icon={UserSquare2}
title="Total Recipients"
title={_(msg`Total Recipients`)}
value={recipientStats.TOTAL_RECIPIENTS}
/>
<CardMetric icon={Mail} title="Documents Received" value={recipientStats.SENT} />
<CardMetric icon={MailOpen} title="Documents Viewed" value={recipientStats.OPENED} />
<CardMetric icon={PenTool} title="Signatures Collected" value={recipientStats.SIGNED} />
<CardMetric
icon={Mail}
title={_(msg`Documents Received`)}
value={recipientStats.SENT}
/>
<CardMetric
icon={MailOpen}
title={_(msg`Documents Viewed`)}
value={recipientStats.OPENED}
/>
<CardMetric
icon={PenTool}
title={_(msg`Signatures Collected`)}
value={recipientStats.SIGNED}
/>
</div>
</div>
</div>
<div className="mt-16">
<h3 className="text-3xl font-semibold">Charts</h3>
<h3 className="text-3xl font-semibold">
<Trans>Charts</Trans>
</h3>
<div className="mt-5 grid grid-cols-2 gap-8">
<UserWithDocumentChart
data={MONTHLY_USERS_SIGNED}
title="MAU (created document)"
tooltip="Monthly Active Users: Users that created at least one Document"
title={_(msg`MAU (created document)`)}
tooltip={_(msg`Monthly Active Users: Users that created at least one Document`)}
/>
<UserWithDocumentChart
data={MONTHLY_USERS_SIGNED}
completed
title="MAU (had document completed)"
tooltip="Monthly Active Users: Users that had at least one of their documents completed"
title={_(msg`MAU (had document completed)`)}
tooltip={_(
msg`Monthly Active Users: Users that had at least one of their documents completed`,
)}
/>
<SignerConversionChart title="Signers that Signed Up" data={signerConversionMonthly} />
<SignerConversionChart
title="Total Signers that Signed Up"
title={_(msg`Total Signers that Signed Up`)}
data={signerConversionMonthly}
cummulative
/>

View File

@ -1,5 +1,8 @@
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions';
import {
Table,
@ -11,20 +14,32 @@ import {
} from '@documenso/ui/primitives/table';
export default async function Subscriptions() {
setupI18nSSR();
const subscriptions = await findSubscriptions();
return (
<div>
<h2 className="text-4xl font-semibold">Manage subscriptions</h2>
<h2 className="text-4xl font-semibold">
<Trans>Manage subscriptions</Trans>
</h2>
<div className="mt-8">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Ends On</TableHead>
<TableHead>User ID</TableHead>
<TableHead>
<Trans>Status</Trans>
</TableHead>
<TableHead>
<Trans>Created At</Trans>
</TableHead>
<TableHead>
<Trans>Ends On</Trans>
</TableHead>
<TableHead>
<Trans>User ID</Trans>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>

View File

@ -4,6 +4,9 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
@ -27,8 +30,10 @@ export type DeleteUserDialogProps = {
};
export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { _ } = useLingui();
const router = useRouter();
const [email, setEmail] = useState('');
@ -43,8 +48,8 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
});
toast({
title: 'Account deleted',
description: 'The account has been deleted successfully.',
title: _(msg`Account deleted`),
description: _(msg`The account has been deleted successfully.`),
duration: 5000,
});
@ -52,17 +57,19 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
title: _(msg`An error occurred`),
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description:
err.message ??
'We encountered an unknown error while attempting to delete your account. Please try again later.',
_(
msg`We encountered an unknown error while attempting to delete your account. Please try again later.`,
),
});
}
}
@ -77,31 +84,39 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
<div>
<AlertTitle>Delete Account</AlertTitle>
<AlertDescription className="mr-2">
Delete the users account and all its contents. This action is irreversible and will
cancel their subscription, so proceed with caution.
<Trans>
Delete the users account and all its contents. This action is irreversible and will
cancel their subscription, so proceed with caution.
</Trans>
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
<Button variant="destructive">
<Trans>Delete Account</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>Delete Account</DialogTitle>
<DialogTitle>
<Trans>Delete Account</Trans>
</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
This action is not reversible. Please be certain.
<Trans>This action is not reversible. Please be certain.</Trans>
</AlertDescription>
</Alert>
</DialogHeader>
<div>
<DialogDescription>
To confirm, please enter the accounts email address <br />({user.email}).
<Trans>
To confirm, please enter the accounts email address <br />({user.email}).
</Trans>
</DialogDescription>
<Input
@ -119,7 +134,7 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
variant="destructive"
disabled={email !== user.email}
>
{isDeletingUser ? 'Deleting account...' : 'Delete Account'}
<Trans>Delete account</Trans>
</Button>
</DialogFooter>
</DialogContent>

View File

@ -1,5 +1,6 @@
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Role } from '@documenso/prisma/client';
@ -59,7 +60,9 @@ const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={selectedValues.join(', ')} />
<CommandEmpty>No value found.</CommandEmpty>
<CommandEmpty>
<Trans>No value found.</Trans>
</CommandEmpty>
<CommandGroup>
{allRoles.map((value: string, i: number) => (
<CommandItem key={i} onSelect={() => handleSelect(value)}>

View File

@ -3,6 +3,8 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
@ -28,7 +30,9 @@ const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
export default function UserPage({ params }: { params: { id: number } }) {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const { data: user } = trpc.profile.getUser.useQuery(
@ -65,14 +69,14 @@ export default function UserPage({ params }: { params: { id: number } }) {
router.refresh();
toast({
title: 'Profile updated',
description: 'Your profile has been updated.',
title: _(msg`Profile updated`),
description: _(msg`Your profile has been updated.`),
duration: 5000,
});
} catch (e) {
toast({
title: 'Error',
description: 'An error occurred while updating your profile.',
title: _(msg`Error`),
description: _(msg`An error occurred while updating your profile.`),
variant: 'destructive',
});
}
@ -80,7 +84,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
return (
<div>
<h2 className="text-4xl font-semibold">Manage {user?.name}'s profile</h2>
<h2 className="text-4xl font-semibold">
<Trans>Manage {user?.name}'s profile</Trans>
</h2>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="mt-6 flex w-full flex-col gap-y-4">
@ -89,7 +95,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Name</FormLabel>
<FormLabel className="text-muted-foreground">
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} value={field.value ?? ''} />
</FormControl>
@ -102,7 +110,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Email</FormLabel>
<FormLabel className="text-muted-foreground">
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
@ -117,7 +127,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
render={({ field: { onChange } }) => (
<FormItem>
<fieldset className="flex flex-col gap-2">
<FormLabel className="text-muted-foreground">Roles</FormLabel>
<FormLabel className="text-muted-foreground">
<Trans>Roles</Trans>
</FormLabel>
<FormControl>
<MultiSelectRoleCombobox
listValues={roles}
@ -132,7 +144,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
Update user
<Trans>Update user</Trans>
</Button>
</div>
</fieldset>

View File

@ -1,15 +1,18 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import { useEffect, useMemo, useState, useTransition } from 'react';
import Link from 'next/link';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Edit, Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Document, Role, Subscription } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input';
@ -45,11 +48,70 @@ export const UsersDataTable = ({
page,
individualPriceIds,
}: UsersDataTableProps) => {
const { _ } = useLingui();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const [searchString, setSearchString] = useState('');
const debouncedSearchString = useDebouncedValue(searchString, 1000);
const columns = useMemo(() => {
return [
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
{
header: _(msg`Name`),
accessorKey: 'name',
cell: ({ row }) => <div>{row.original.name}</div>,
},
{
header: _(msg`Email`),
accessorKey: 'email',
cell: ({ row }) => <div>{row.original.email}</div>,
},
{
header: _(msg`Roles`),
accessorKey: 'roles',
cell: ({ row }) => row.original.roles.join(', '),
},
{
header: _(msg`Subscription`),
accessorKey: 'subscription',
cell: ({ row }) => {
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
individualPriceIds.includes(sub.priceId),
);
return foundIndividualSubscription?.status ?? 'NONE';
},
},
{
header: _(msg`Documents`),
accessorKey: 'documents',
cell: ({ row }) => {
return <div>{row.original.Document.length}</div>;
},
},
{
header: '',
accessorKey: 'edit',
cell: ({ row }) => {
return (
<Button className="w-24" asChild>
<Link href={`/admin/users/${row.original.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>
</Button>
);
},
},
] satisfies DataTableColumnDef<(typeof users)[number]>[];
}, [individualPriceIds]);
useEffect(() => {
startTransition(() => {
updateSearchParams({
@ -79,65 +141,12 @@ export const UsersDataTable = ({
<Input
className="my-6 flex flex-row gap-4"
type="text"
placeholder="Search by name or email"
placeholder={_(msg`Search by name or email`)}
value={searchString}
onChange={handleChange}
/>
<DataTable
columns={[
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
{
header: 'Name',
accessorKey: 'name',
cell: ({ row }) => <div>{row.original.name}</div>,
},
{
header: 'Email',
accessorKey: 'email',
cell: ({ row }) => <div>{row.original.email}</div>,
},
{
header: 'Roles',
accessorKey: 'roles',
cell: ({ row }) => row.original.roles.join(', '),
},
{
header: 'Subscription',
accessorKey: 'subscription',
cell: ({ row }) => {
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
individualPriceIds.includes(sub.priceId),
);
return foundIndividualSubscription?.status ?? 'NONE';
},
},
{
header: 'Documents',
accessorKey: 'documents',
cell: ({ row }) => {
return <div>{row.original.Document.length}</div>;
},
},
{
header: '',
accessorKey: 'edit',
cell: ({ row }) => {
return (
<Button className="w-24" asChild>
<Link href={`/admin/users/${row.original.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>
</Button>
);
},
},
]}
columns={columns}
data={users}
perPage={perPage}
currentPage={page}

View File

@ -1,4 +1,7 @@
import { Trans } from '@lingui/macro';
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { UsersDataTable } from './data-table-users';
@ -13,6 +16,8 @@ type AdminManageUsersProps = {
};
export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) {
setupI18nSSR();
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 10;
const searchString = searchParams.search || '';
@ -26,7 +31,10 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
return (
<div>
<h2 className="text-4xl font-semibold">Manage users</h2>
<h2 className="text-4xl font-semibold">
<Trans>Manage users</Trans>
</h2>
<UsersDataTable
users={users}
individualPriceIds={individualPriceIds}

View File

@ -2,6 +2,8 @@
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
@ -26,6 +28,7 @@ export type DocumentPageViewButtonProps = {
export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
if (!session) {
return null;
@ -57,8 +60,8 @@ export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButto
await downloadPDF({ documentData, fileName: documentWithData.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while downloading your document.',
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
@ -77,19 +80,19 @@ export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButto
.with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
<Trans>Sign</Trans>
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
Approve
<Trans>Approve</Trans>
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
<Trans>View</Trans>
</>
))}
</Link>
@ -97,13 +100,15 @@ export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButto
))
.with({ isComplete: false }, () => (
<Button className="w-full" asChild>
<Link href={`${documentsPath}/${document.id}/edit`}>Edit</Link>
<Link href={`${documentsPath}/${document.id}/edit`}>
<Trans>Edit</Trans>
</Link>
</Button>
))
.with({ isComplete: true }, () => (
<Button className="w-full" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
Download
<Trans>Download</Trans>
</Button>
))
.otherwise(() => null);

View File

@ -4,6 +4,8 @@ import { useState } from 'react';
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
Copy,
Download,
@ -47,6 +49,7 @@ export type DocumentPageViewDropdownProps = {
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
@ -82,8 +85,8 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
await downloadPDF({ documentData, fileName: document.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while downloading your document.',
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
@ -98,13 +101,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="end" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuLabel>
<Trans>Action</Trans>
</DropdownMenuLabel>
{(isOwner || isCurrentTeamDocument) && !isComplete && (
<DropdownMenuItem asChild>
<Link href={`${documentsPath}/${document.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
<Trans>Edit</Trans>
</Link>
</DropdownMenuItem>
)}
@ -112,20 +117,20 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
{isComplete && (
<DropdownMenuItem onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
Download
<Trans>Download</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href={`${documentsPath}/${document.id}/logs`}>
<ScrollTextIcon className="mr-2 h-4 w-4" />
Audit Log
<Trans>Audit Log</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem
@ -133,10 +138,12 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
<Trans>Delete</Trans>
</DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel>
<DropdownMenuLabel>
<Trans>Share</Trans>
</DropdownMenuLabel>
<ResendDocumentActionItem
document={document}
@ -151,7 +158,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
<div className="flex items-center">
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
Share Signing Card
<Trans>Share Signing Card</Trans>
</div>
</DropdownMenuItem>
)}

View File

@ -2,6 +2,8 @@
import { useMemo } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
@ -23,6 +25,7 @@ export const DocumentPageViewInformation = ({
const isMounted = useIsMounted();
const { locale } = useLocale();
const { _ } = useLingui();
const documentInformation = useMemo(() => {
let createdValue = DateTime.fromJSDate(document.createdAt).toFormat('MMMM d, yyyy');
@ -38,31 +41,34 @@ export const DocumentPageViewInformation = ({
return [
{
description: 'Uploaded by',
value: userId === document.userId ? 'You' : document.User.name ?? document.User.email,
description: msg`Uploaded by`,
value: userId === document.userId ? _(msg`You`) : document.User.name ?? document.User.email,
},
{
description: 'Created',
description: msg`Created`,
value: createdValue,
},
{
description: 'Last modified',
description: msg`Last modified`,
value: lastModifiedValue,
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, document, locale, userId]);
return (
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
<h1 className="px-4 py-3 font-medium">Information</h1>
<h1 className="px-4 py-3 font-medium">
<Trans>Information</Trans>
</h1>
<ul className="divide-y border-t">
{documentInformation.map((item) => (
{documentInformation.map((item, i) => (
<li
key={item.description}
key={i}
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
>
<span className="text-muted-foreground">{item.description}</span>
<span className="text-muted-foreground">{_(item.description)}</span>
<span>{item.value}</span>
</li>
))}

View File

@ -2,6 +2,8 @@
import { useMemo } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
@ -21,6 +23,8 @@ export const DocumentPageViewRecentActivity = ({
documentId,
userId,
}: DocumentPageViewRecentActivityProps) => {
const { _ } = useLingui();
const {
data,
isLoading,
@ -49,7 +53,9 @@ export const DocumentPageViewRecentActivity = ({
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
<h1 className="text-foreground font-medium">Recent activity</h1>
<h1 className="text-foreground font-medium">
<Trans>Recent activity</Trans>
</h1>
{/* Can add dropdown menu here for additional options. */}
</div>
@ -62,12 +68,14 @@ export const DocumentPageViewRecentActivity = ({
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center py-16">
<p className="text-foreground/80 text-sm">Unable to load document history</p>
<p className="text-foreground/80 text-sm">
<Trans>Unable to load document history</Trans>
</p>
<button
onClick={async () => refetch()}
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
>
Click here to retry
<Trans>Click here to retry</Trans>
</button>
</div>
)}
@ -89,14 +97,16 @@ export const DocumentPageViewRecentActivity = ({
onClick={async () => fetchNextPage()}
className="text-foreground/70 hover:text-muted-foreground text-xs"
>
{isFetchingNextPage ? 'Loading...' : 'Load older activity'}
{isFetchingNextPage ? _(msg`Loading...`) : _(msg`Load older activity`)}
</button>
</li>
)}
{documentAuditLogs.length === 0 && (
<div className="flex items-center justify-center py-4">
<p className="text-muted-foreground/70 text-sm">No recent activity</p>
<p className="text-muted-foreground/70 text-sm">
<Trans>No recent activity</Trans>
</p>
</div>
)}
@ -133,6 +143,7 @@ export const DocumentPageViewRecentActivity = ({
))}
</div>
{/* Todo: Translations. */}
<p
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
title={`${formatDocumentAuditLogAction(auditLog, userId).prefix} ${

View File

@ -1,5 +1,7 @@
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
import { match } from 'ts-pattern';
@ -21,17 +23,21 @@ export const DocumentPageViewRecipients = ({
document,
documentRootPath,
}: DocumentPageViewRecipientsProps) => {
const { _ } = useLingui();
const recipients = document.Recipient;
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">Recipients</h1>
<h1 className="text-foreground font-medium">
<Trans>Recipients</Trans>
</h1>
{document.status !== DocumentStatus.COMPLETED && (
<Link
href={`${documentRootPath}/${document.id}/edit?step=signers`}
title="Modify recipients"
title={_(msg`Modify recipients`)}
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
@ -45,7 +51,9 @@ export const DocumentPageViewRecipients = ({
<ul className="text-muted-foreground divide-y border-t">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">No recipients</li>
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
</li>
)}
{recipients.map((recipient) => (
@ -55,7 +63,7 @@ export const DocumentPageViewRecipients = ({
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
/>
@ -67,19 +75,19 @@ export const DocumentPageViewRecipients = ({
.with(RecipientRole.APPROVER, () => (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Approved
<Trans>Approved</Trans>
</>
))
.with(RecipientRole.CC, () =>
document.status === DocumentStatus.COMPLETED ? (
<>
<MailIcon className="mr-1 h-3 w-3" />
Sent
<Trans>Sent</Trans>
</>
) : (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Ready
<Trans>Ready</Trans>
</>
),
)
@ -87,13 +95,13 @@ export const DocumentPageViewRecipients = ({
.with(RecipientRole.SIGNER, () => (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
Signed
<Trans>Signed</Trans>
</>
))
.with(RecipientRole.VIEWER, () => (
<>
<MailOpenIcon className="mr-1 h-3 w-3" />
Viewed
<Trans>Viewed</Trans>
</>
))
.exhaustive()}
@ -104,7 +112,7 @@ export const DocumentPageViewRecipients = ({
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
Pending
<Trans>Pending</Trans>
</Badge>
)}
</li>

View File

@ -1,6 +1,8 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Plural, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
import { match } from 'ts-pattern';
@ -42,6 +44,7 @@ export type DocumentPageViewProps = {
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
const { id } = params;
const { _ } = useLingui();
const documentId = Number(id);
@ -107,7 +110,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
<Trans>Documents</Trans>
</Link>
<div className="flex flex-row justify-between truncate">
@ -132,12 +135,18 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span>
<span>
<Trans>{recipients.length} Recipient(s)</Trans>
</span>
</StackAvatarsWithTooltip>
</div>
)}
{document.deletedAt && <Badge variant="destructive">Document deleted</Badge>}
{document.deletedAt && (
<Badge variant="destructive">
<Trans>Document deleted</Trans>
</Badge>
)}
</div>
</div>
@ -146,7 +155,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<DocumentHistorySheet documentId={document.id} userId={user.id}>
<Button variant="outline">
<Clock9 className="mr-1.5 h-4 w-4" />
Document history
<Trans>Document history</Trans>
</Button>
</DocumentHistorySheet>
</div>
@ -172,7 +181,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
<div className="flex flex-row items-center justify-between px-4">
<h3 className="text-foreground text-2xl font-semibold">
Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
{_(FRIENDLY_STATUS_MAP[document.status].labelExtended)}
</h3>
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
@ -180,22 +189,24 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<p className="text-muted-foreground mt-2 px-4 text-sm ">
{match(document.status)
.with(
DocumentStatus.COMPLETED,
() => 'This document has been signed by all recipients',
)
.with(
DocumentStatus.DRAFT,
() => 'This document is currently a draft and has not been sent',
)
.with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans>
))
.with(DocumentStatus.DRAFT, () => (
<Trans>This document is currently a draft and has not been sent</Trans>
))
.with(DocumentStatus.PENDING, () => {
const pendingRecipients = recipients.filter(
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
);
return `Waiting on ${pendingRecipients.length} recipient${
pendingRecipients.length > 1 ? 's' : ''
}`;
return (
<Plural
value={pendingRecipients.length}
one="Waiting on 1 recipient"
other="Waiting on # recipients"
/>
);
})
.exhaustive()}
</p>

View File

@ -4,6 +4,9 @@ import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
@ -45,6 +48,7 @@ export const EditDocumentForm = ({
isDocumentEnterprise,
}: EditDocumentFormProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const router = useRouter();
const searchParams = useSearchParams();
@ -125,23 +129,23 @@ export const EditDocumentForm = ({
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
settings: {
title: 'General',
description: 'Configure general settings for the document.',
title: msg`General`,
description: msg`Configure general settings for the document.`,
stepIndex: 1,
},
signers: {
title: 'Add Signers',
description: 'Add the people who will sign the document.',
title: msg`Add Signers`,
description: msg`Add the people who will sign the document.`,
stepIndex: 2,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
title: msg`Add Fields`,
description: msg`Add all relevant fields for each recipient.`,
stepIndex: 3,
},
subject: {
title: 'Add Subject',
description: 'Add the subject and message you wish to send to signers.',
title: msg`Add Subject`,
description: msg`Add the subject and message you wish to send to signers.`,
stepIndex: 4,
},
};
@ -191,8 +195,8 @@ export const EditDocumentForm = ({
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while updating the document settings.',
title: _(msg`Error`),
description: _(msg`An error occurred while updating the document settings.`),
variant: 'destructive',
});
}
@ -218,8 +222,8 @@ export const EditDocumentForm = ({
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
title: _(msg`Error`),
description: _(msg`An error occurred while adding signers.`),
variant: 'destructive',
});
}
@ -248,8 +252,8 @@ export const EditDocumentForm = ({
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while adding the fields.',
title: _(msg`Error`),
description: _(msg`An error occurred while adding the fields.`),
variant: 'destructive',
});
}
@ -269,8 +273,8 @@ export const EditDocumentForm = ({
});
toast({
title: 'Document sent',
description: 'Your document has been sent successfully.',
title: _(msg`Document sent`),
description: _(msg`Your document has been sent successfully.`),
duration: 5000,
});
@ -279,8 +283,8 @@ export const EditDocumentForm = ({
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while sending the document.',
title: _(msg`Error`),
description: _(msg`An error occurred while sending the document.`),
variant: 'destructive',
});
}

View File

@ -1,6 +1,7 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Plural, Trans } from '@lingui/macro';
import { ChevronLeft, Users2 } from 'lucide-react';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
@ -78,7 +79,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
<Trans>Documents</Trans>
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
@ -97,7 +98,9 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span>
<span>
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
</span>
</StackAvatarsWithTooltip>
</div>
)}

View File

@ -1,3 +1,5 @@
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { DocumentEditPageView } from './document-edit-page-view';
export type DocumentPageProps = {
@ -7,5 +9,7 @@ export type DocumentPageProps = {
};
export default function DocumentEditPage({ params }: DocumentPageProps) {
setupI18nSSR();
return <DocumentEditPageView params={params} />;
}

View File

@ -1,19 +1,23 @@
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { ChevronLeft, Loader } from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
export default function Loading() {
setupI18nSSR();
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
<Trans>Documents</Trans>
</Link>
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
Loading Document...
<Trans>Loading Document...</Trans>
</h1>
<div className="flex h-10 items-center">
@ -25,7 +29,9 @@ export default function Loading() {
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p>
<p className="text-muted-foreground mt-4">
<Trans>Loading document...</Trans>
</p>
</div>
</div>

View File

@ -1,7 +1,11 @@
'use client';
import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { UAParser } from 'ua-parser-js';
@ -10,6 +14,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { trpc } from '@documenso/trpc/react';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
@ -27,7 +32,7 @@ const dateFormat: DateTimeFormatOptions = {
};
export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => {
const parser = new UAParser();
const { _ } = useLingui();
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
@ -66,64 +71,70 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
totalPages: 1,
};
const columns = useMemo(() => {
const parser = new UAParser();
return [
{
header: _(msg`Time`),
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
},
{
header: _(msg`User`),
accessorKey: 'name',
cell: ({ row }) =>
row.original.name || row.original.email ? (
<div>
{row.original.name && (
<p className="truncate" title={row.original.name}>
{row.original.name}
</p>
)}
{row.original.email && (
<p className="truncate" title={row.original.email}>
{row.original.email}
</p>
)}
</div>
) : (
<p>N/A</p>
),
},
{
header: _(msg`Action`),
accessorKey: 'type',
cell: ({ row }) => (
<span>
{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}
</span>
),
},
{
header: 'IP Address',
accessorKey: 'ipAddress',
},
{
header: 'Browser',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
return result.browser.name ?? 'N/A';
},
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<DataTable
columns={[
{
header: 'Time',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
},
{
header: 'User',
accessorKey: 'name',
cell: ({ row }) =>
row.original.name || row.original.email ? (
<div>
{row.original.name && (
<p className="truncate" title={row.original.name}>
{row.original.name}
</p>
)}
{row.original.email && (
<p className="truncate" title={row.original.email}>
{row.original.email}
</p>
)}
</div>
) : (
<p>N/A</p>
),
},
{
header: 'Action',
accessorKey: 'type',
cell: ({ row }) => (
<span>
{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}
</span>
),
},
{
header: 'IP Address',
accessorKey: 'ipAddress',
},
{
header: 'Browser',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
return result.browser.name ?? 'N/A';
},
},
]}
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}

View File

@ -1,6 +1,9 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import type { MessageDescriptor } from '@lingui/core';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { ChevronLeft } from 'lucide-react';
import { DateTime } from 'luxon';
@ -29,6 +32,8 @@ export type DocumentLogsPageViewProps = {
};
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
const { _ } = useLingui();
const locale = getLocale();
const { id } = params;
@ -60,39 +65,39 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
redirect(documentRootPath);
}
const documentInformation: { description: string; value: string }[] = [
const documentInformation: { description: MessageDescriptor; value: string }[] = [
{
description: 'Document title',
description: msg`Document title`,
value: document.title,
},
{
description: 'Document ID',
description: msg`Document ID`,
value: document.id.toString(),
},
{
description: 'Document status',
value: FRIENDLY_STATUS_MAP[document.status].label,
description: msg`Document status`,
value: _(FRIENDLY_STATUS_MAP[document.status].label),
},
{
description: 'Created by',
description: msg`Created by`,
value: document.User.name
? `${document.User.name} (${document.User.email})`
: document.User.email,
},
{
description: 'Date created',
description: msg`Date created`,
value: DateTime.fromJSDate(document.createdAt)
.setLocale(locale)
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
},
{
description: 'Last updated',
description: msg`Last updated`,
value: DateTime.fromJSDate(document.updatedAt)
.setLocale(locale)
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
},
{
description: 'Time zone',
description: msg`Time zone`,
value: document.documentMeta?.timezone ?? 'N/A',
},
];
@ -114,7 +119,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
className="flex items-center text-[#7AC455] hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Document
<Trans>Document</Trans>
</Link>
<div className="flex flex-col justify-between truncate sm:flex-row">
@ -147,7 +152,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
{documentInformation.map((info, i) => (
<div className="text-foreground text-sm" key={i}>
<h3 className="font-semibold">{info.description}</h3>
<h3 className="font-semibold">{_(info.description)}</h3>
<p className="text-muted-foreground">{info.value}</p>
</div>
))}

View File

@ -1,5 +1,7 @@
'use client';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DownloadIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
@ -19,6 +21,7 @@ export const DownloadAuditLogButton = ({
documentId,
}: DownloadAuditLogButtonProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const { mutateAsync: downloadAuditLogs, isLoading } =
trpc.document.downloadAuditLogs.useMutation();
@ -59,8 +62,10 @@ export const DownloadAuditLogButton = ({
console.error(error);
toast({
title: 'Something went wrong',
description: 'Sorry, we were unable to download the audit logs. Please try again later.',
title: _(msg`Something went wrong`),
description: _(
msg`Sorry, we were unable to download the audit logs. Please try again later.`,
),
variant: 'destructive',
});
}
@ -73,7 +78,7 @@ export const DownloadAuditLogButton = ({
onClick={() => void onDownloadAuditLogsClick()}
>
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
Download Audit Logs
<Trans>Download Audit Logs</Trans>
</Button>
);
};

View File

@ -1,5 +1,7 @@
'use client';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DownloadIcon } from 'lucide-react';
import { DocumentStatus } from '@documenso/prisma/client';
@ -20,6 +22,7 @@ export const DownloadCertificateButton = ({
documentStatus,
}: DownloadCertificateButtonProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const { mutateAsync: downloadCertificate, isLoading } =
trpc.document.downloadCertificate.useMutation();
@ -60,8 +63,10 @@ export const DownloadCertificateButton = ({
console.error(error);
toast({
title: 'Something went wrong',
description: 'Sorry, we were unable to download the certificate. Please try again later.',
title: _(msg`Something went wrong`),
description: _(
msg`Sorry, we were unable to download the certificate. Please try again later.`,
),
variant: 'destructive',
});
}
@ -76,7 +81,7 @@ export const DownloadCertificateButton = ({
onClick={() => void onDownloadCertificatesClick()}
>
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
Download Certificate
<Trans>Download Certificate</Trans>
</Button>
);
};

View File

@ -1,3 +1,5 @@
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { DocumentLogsPageView } from './document-logs-page-view';
export type DocumentsLogsPageProps = {
@ -7,5 +9,7 @@ export type DocumentsLogsPageProps = {
};
export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
setupI18nSSR();
return <DocumentLogsPageView params={params} />;
}

View File

@ -1,3 +1,5 @@
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { DocumentPageView } from './document-page-view';
export type DocumentPageProps = {
@ -7,5 +9,7 @@ export type DocumentPageProps = {
};
export default function DocumentPage({ params }: DocumentPageProps) {
setupI18nSSR();
return <DocumentPageView params={params} />;
}

View File

@ -1,17 +1,22 @@
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { ChevronLeft } from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
export default function DocumentSentPage() {
setupI18nSSR();
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
<Trans>Documents</Trans>
</Link>
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
Loading Document...
<Trans>Loading Document...</Trans>
</h1>
</div>
);

View File

@ -3,6 +3,8 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { History } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
@ -62,6 +64,7 @@ export const ResendDocumentActionItem = ({
}: ResendDocumentActionItemProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === session?.user?.id;
@ -91,16 +94,16 @@ export const ResendDocumentActionItem = ({
await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
toast({
title: 'Document re-sent',
description: 'Your document has been re-sent successfully.',
title: _(msg`Document re-sent`),
description: _(msg`Your document has been re-sent successfully.`),
duration: 5000,
});
setIsOpen(false);
} catch (err) {
toast({
title: 'Something went wrong',
description: 'This document could not be re-sent at this time. Please try again.',
title: _(msg`Something went wrong`),
description: _(msg`This document could not be re-sent at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
@ -112,14 +115,16 @@ export const ResendDocumentActionItem = ({
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
Resend
<Trans>Resend</Trans>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle asChild>
<h1 className="text-center text-xl">Who do you want to remind?</h1>
<h1 className="text-center text-xl">
<Trans>Who do you want to remind?</Trans>
</h1>
</DialogTitle>
</DialogHeader>
@ -178,12 +183,12 @@ export const ResendDocumentActionItem = ({
variant="secondary"
disabled={isSubmitting}
>
Cancel
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
Send reminder
<Trans>Send reminder</Trans>
</Button>
</div>
</DialogFooter>

View File

@ -2,6 +2,8 @@
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
@ -27,6 +29,7 @@ export type DataTableActionButtonProps = {
export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
if (!session) {
return null;
@ -69,8 +72,8 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while downloading your document.',
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
@ -96,7 +99,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
<Button className="w-32" asChild>
<Link href={`${documentsPath}/${row.id}/edit`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
<Trans>Edit</Trans>
</Link>
</Button>
),
@ -108,19 +111,19 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
.with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
<Trans>Sign</Trans>
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
Approve
<Trans>Approve</Trans>
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
<Trans>View</Trans>
</>
))}
</Link>
@ -129,13 +132,13 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
.with({ isPending: true, isSigned: true }, () => (
<Button className="w-32" disabled={true}>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
<Trans>View</Trans>
</Button>
))
.with({ isComplete: true }, () => (
<Button className="w-32" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
Download
<Trans>Download</Trans>
</Button>
))
.otherwise(() => <div></div>);

View File

@ -4,6 +4,8 @@ import { useState } from 'react';
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
CheckCircle,
Copy,
@ -52,6 +54,7 @@ export type DataTableActionDropdownProps = {
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
@ -98,8 +101,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while downloading your document.',
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
@ -114,7 +117,9 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuLabel>
<Trans>Action</Trans>
</DropdownMenuLabel>
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
@ -122,21 +127,21 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
{recipient?.role === RecipientRole.VIEWER && (
<>
<EyeIcon className="mr-2 h-4 w-4" />
View
<Trans>View</Trans>
</>
)}
{recipient?.role === RecipientRole.SIGNER && (
<>
<Pencil className="mr-2 h-4 w-4" />
Sign
<Trans>Sign</Trans>
</>
)}
{recipient?.role === RecipientRole.APPROVER && (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
<Trans>Approve</Trans>
</>
)}
</Link>
@ -146,25 +151,25 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
<Link href={`${documentsPath}/${row.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
<Trans>Edit</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
Download
<Trans>Download</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
<Trans>Duplicate</Trans>
</DropdownMenuItem>
{/* We don't want to allow teams moving documents across at the moment. */}
{!team && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<MoveRight className="mr-2 h-4 w-4" />
Move to Team
<Trans>Move to Team</Trans>
</DropdownMenuItem>
)}
@ -179,10 +184,12 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
disabled={Boolean(!canManageDocument && team?.teamEmail)}
>
<Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? 'Delete' : 'Hide'}
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel>
<DropdownMenuLabel>
<Trans>Share</Trans>
</DropdownMenuLabel>
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
@ -193,7 +200,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
<div className="flex items-center">
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
Share Signing Card
<Trans>Share Signing Card</Trans>
</div>
</DropdownMenuItem>
)}

View File

@ -2,6 +2,9 @@
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { trpc } from '@documenso/trpc/react';
@ -12,6 +15,8 @@ type DataTableSenderFilterProps = {
};
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
const { _ } = useLingui();
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
@ -49,11 +54,13 @@ export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) =>
<MultiSelectCombobox
emptySelectionPlaceholder={
<p className="text-muted-foreground font-normal">
<span className="text-muted-foreground/70">Sender:</span> All
<Trans>
<span className="text-muted-foreground/70">Sender:</span> All
</Trans>
</p>
}
enableClearAllButton={true}
inputPlaceholder="Search"
inputPlaceholder={msg`Search`}
loading={!isMounted || isInitialLoading}
options={comboBoxOptions}
selectedValues={senderIds}

View File

@ -1,7 +1,9 @@
'use client';
import { useTransition } from 'react';
import { useMemo, useTransition } from 'react';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSession } from 'next-auth/react';
@ -10,6 +12,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@ -39,10 +42,63 @@ export const DocumentsDataTable = ({
team,
}: DocumentsDataTableProps) => {
const { data: session } = useSession();
const { _ } = useLingui();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const columns = useMemo(() => {
return [
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => (
<LocaleDate
date={row.original.createdAt}
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
/>
),
},
{
header: _(msg`Title`),
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
},
{
id: 'sender',
header: _(msg`Sender`),
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
},
{
header: _(msg`Recipient`),
accessorKey: 'recipient',
cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.Recipient}
documentStatus={row.original.status}
/>
),
},
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
size: 140,
},
{
header: _(msg`Actions`),
cell: ({ row }) =>
(!row.original.deletedAt ||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
<div className="flex items-center gap-x-4">
<DataTableActionButton team={team} row={row.original} />
<DataTableActionDropdown team={team} row={row.original} />
</div>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, [team]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
@ -59,54 +115,7 @@ export const DocumentsDataTable = ({
return (
<div className="relative">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => (
<LocaleDate
date={row.original.createdAt}
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
/>
),
},
{
header: 'Title',
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
},
{
id: 'sender',
header: 'Sender',
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
},
{
header: 'Recipient',
accessorKey: 'recipient',
cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.Recipient}
documentStatus={row.original.status}
/>
),
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
size: 140,
},
{
header: 'Actions',
cell: ({ row }) =>
(!row.original.deletedAt ||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
<div className="flex items-center gap-x-4">
<DataTableActionButton team={team} row={row.original} />
<DataTableActionDropdown team={team} row={row.original} />
</div>
),
},
]}
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}

View File

@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
@ -43,6 +45,7 @@ export const DeleteDocumentDialog = ({
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { _ } = useLingui();
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
@ -53,8 +56,8 @@ export const DeleteDocumentDialog = ({
void refreshLimits();
toast({
title: 'Document deleted',
description: `"${documentTitle}" has been successfully deleted`,
title: _(msg`Document deleted`),
description: _(msg`"${documentTitle}" has been successfully deleted`),
duration: 5000,
});
@ -74,8 +77,8 @@ export const DeleteDocumentDialog = ({
await deleteDocument({ id, teamId });
} catch {
toast({
title: 'Something went wrong',
description: 'This document could not be deleted at this time. Please try again.',
title: _(msg`Something went wrong`),
description: _(msg`This document could not be deleted at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
@ -91,11 +94,20 @@ export const DeleteDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
You are about to {canManageDocument ? 'delete' : 'hide'}{' '}
<strong>"{documentTitle}"</strong>
{canManageDocument ? (
<Trans>
You are about to delete <strong>"{documentTitle}"</strong>
</Trans>
) : (
<Trans>
You are about to hide <strong>"{documentTitle}"</strong>
</Trans>
)}
</DialogDescription>
</DialogHeader>
@ -104,33 +116,53 @@ export const DeleteDocumentDialog = ({
{match(status)
.with(DocumentStatus.DRAFT, () => (
<AlertDescription>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</Trans>
</AlertDescription>
))
.with(DocumentStatus.PENDING, () => (
<AlertDescription>
<p>
Please note that this action is <strong>irreversible</strong>.
<Trans>
Please note that this action is <strong>irreversible</strong>.
</Trans>
</p>
<p className="mt-1">Once confirmed, the following will occur:</p>
<p className="mt-1">
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>Document will be permanently deleted</li>
<li>Document signing process will be cancelled</li>
<li>All inserted signatures will be voided</li>
<li>All recipients will be notified</li>
<li>
<Trans>Document will be permanently deleted</Trans>
</li>
<li>
<Trans>Document signing process will be cancelled</Trans>
</li>
<li>
<Trans>All inserted signatures will be voided</Trans>
</li>
<li>
<Trans>All recipients will be notified</Trans>
</li>
</ul>
</AlertDescription>
))
.with(DocumentStatus.COMPLETED, () => (
<AlertDescription>
<p>By deleting this document, the following will occur:</p>
<p>
<Trans>By deleting this document, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>The document will be hidden from your account</li>
<li>Recipients will still retain their copy of the document</li>
<li>
<Trans>The document will be hidden from your account</Trans>
</li>
<li>
<Trans>Recipients will still retain their copy of the document</Trans>
</li>
</ul>
</AlertDescription>
))
@ -139,7 +171,7 @@ export const DeleteDocumentDialog = ({
) : (
<Alert variant="warning" className="-mt-1">
<AlertDescription>
Please contact support if you would like to revert this action.
<Trans>Please contact support if you would like to revert this action.</Trans>
</AlertDescription>
</Alert>
)}
@ -149,13 +181,13 @@ export const DeleteDocumentDialog = ({
type="text"
value={inputValue}
onChange={onInputChange}
placeholder="Type 'delete' to confirm"
placeholder={_(msg`Type 'delete' to confirm`)}
/>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button
@ -165,7 +197,7 @@ export const DeleteDocumentDialog = ({
disabled={!isDeleteEnabled && canManageDocument}
variant="destructive"
>
{canManageDocument ? 'Delete' : 'Hide'}
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -1,5 +1,7 @@
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
@ -104,7 +106,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
</Avatar>
)}
<h1 className="text-4xl font-semibold">Documents</h1>
<h1 className="text-4xl font-semibold">
<Trans>Documents</Trans>
</h1>
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">

View File

@ -1,5 +1,8 @@
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
@ -28,7 +31,9 @@ export const DuplicateDocumentDialog = ({
team,
}: DuplicateDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { _ } = useLingui();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
id,
@ -50,8 +55,8 @@ export const DuplicateDocumentDialog = ({
router.push(`${documentsPath}/${newId}/edit`);
toast({
title: 'Document Duplicated',
description: 'Your document has been successfully duplicated.',
title: _(msg`Document Duplicated`),
description: _(msg`Your document has been successfully duplicated.`),
duration: 5000,
});
@ -64,8 +69,8 @@ export const DuplicateDocumentDialog = ({
await duplicateDocument({ id, teamId: team?.id });
} catch {
toast({
title: 'Something went wrong',
description: 'This document could not be duplicated at this time. Please try again.',
title: _(msg`Something went wrong`),
description: _(msg`This document could not be duplicated at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
@ -76,12 +81,14 @@ export const DuplicateDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Duplicate</DialogTitle>
<DialogTitle>
<Trans>Duplicate</Trans>
</DialogTitle>
</DialogHeader>
{!documentData || isLoading ? (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
Loading Document...
<Trans>Loading Document...</Trans>
</h1>
</div>
) : (
@ -98,7 +105,7 @@ export const DuplicateDocumentDialog = ({
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button
@ -108,7 +115,7 @@ export const DuplicateDocumentDialog = ({
onClick={onDuplicate}
className="flex-1"
>
Duplicate
<Trans>Duplicate</Trans>
</Button>
</div>
</DialogFooter>

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