Compare commits

..

40 Commits

Author SHA1 Message Date
12eb82629e fix: build errors 2025-03-03 17:17:45 +00:00
108060cc9a Merge branch 'main' into power-signer 2025-02-28 21:11:35 +11:00
1789eff564 chore: add label for checkbox and radio fields (#1607) 2025-02-28 21:09:38 +11:00
235d846d2b v1.9.1-rc.7 2025-02-28 10:11:36 +11:00
ca3d65ad10 fix: remove auto-expand in embeddding 2025-02-28 10:11:08 +11:00
617e3a46e0 v1.9.1-rc.6 2025-02-28 09:10:16 +11:00
255c33cdab fix: stripe price fetch (#1677)
Currently Stripe prices search is omitting a price for an unknown
reason.

Changed our fetch logic to use `list` instead of `search` allows us to
work around the issue.

It's unknown on the performance impact of using `list` vs `search`
2025-02-28 09:04:25 +11:00
1560218d4a v1.9.1-rc.5 2025-02-27 11:44:19 +11:00
5c7768c253 fix: improve embed mobile experience 2025-02-27 11:43:42 +11:00
7bb93e4233 docs: add documentation for embedding via web components (#1670)
The web components version of embedding was missing documentation. I've
added it here. Let me know if there's anything that should be
changed/updated.
2025-02-26 16:04:05 +11:00
42e39f7ef1 v1.9.1-rc.4 2025-02-26 15:28:51 +11:00
838e399c73 fix: handle empty field meta for checkboxes 2025-02-26 15:27:44 +11:00
b6a891acc8 docs: add the v2 api staging base url (#1671) 2025-02-25 14:02:12 +02:00
84b4d58856 chore: update readme (#1664)
Resolve external link issue
2025-02-25 16:59:20 +11:00
c51c32fdc6 feat: search by externalId 2025-02-25 09:44:40 +11:00
59c1e55233 v1.9.1-rc.3 2025-02-25 08:03:13 +11:00
2fbaf56c06 fix: early adopters can use platform features 2025-02-25 07:54:28 +11:00
70320cd24b chore: update API documentation 2025-02-25 02:35:11 +11:00
00b46561c2 v1.9.1-rc.2 2025-02-20 11:35:03 +11:00
11bc93a9a4 feat: allow document rejection in embeds (#1662)
Bing bang
2025-02-20 11:34:19 +11:00
11528090a5 fix: prepare auth migration (#1648)
Add schema session migration in preparation for auth migration.
2025-02-18 15:17:47 +11:00
3c4863f285 chore: add asssitant role to the docs (#1638) 2025-02-17 15:42:37 +11:00
ad7720b778 fix: merge conflicts 2025-02-06 12:23:46 +00:00
b13cd61731 Merge branch 'main' into power-signer 2024-11-21 17:21:08 +00:00
43e5bcf1df fix: match translations with main 2024-11-19 07:47:58 +00:00
26d63690c5 chore: add gitignore 2024-11-19 07:40:56 +00:00
26640e1fec fix: translations 2024-11-19 07:22:44 +00:00
044182966b Merge branch 'main' into power-signer 2024-11-19 07:19:02 +00:00
b07139c0d2 chore: merge with main 2024-11-18 07:32:10 +00:00
beafc366f7 fix: merge conflicts 2024-11-15 10:50:31 +00:00
adcdf2df58 Merge branch 'main' into power-signer 2024-10-30 00:24:43 +00:00
5210256ae1 fix: translations conflicts 2024-10-28 10:21:26 +00:00
807e65d7e6 chore: delete translations 2024-10-28 10:19:36 +00:00
b3f2ab7f95 fix: translations 2024-10-28 10:16:59 +00:00
066f88653e chore: remove duplicate property 2024-10-28 10:13:16 +00:00
0e426dd1d1 fix: merge conflicts 2024-10-28 10:09:49 +00:00
95b95a2614 Merge branch 'main' into power-signer 2024-10-16 14:10:55 +00:00
733a300c93 fix: merge conflicts 2024-10-15 17:03:25 +00:00
b3ade016e1 chore: use shadcn sheets 2024-10-11 18:43:09 +00:00
eb96f315b6 feat: power signing mode 2024-10-10 20:23:32 +00:00
56 changed files with 4072 additions and 9544 deletions

View File

@ -1,4 +1,4 @@
> 🚨 We are live on Product Hunt 🎉 Check out our latest launch: <a href="documen.so/sign-everywhere">The Platform Plan</a>! > 🚨 We are live on Product Hunt 🎉 Check out our latest launch: <a href="https://documen.so/sign-everywhere">The Platform Plan</a>!
<a href="https://www.producthunt.com/posts/documenso-platform-plan?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-documenso&#0045;platform&#0045;plan" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=670576&theme=light" alt="Documenso&#0032;Platform&#0032;Plan - Whitelabeled&#0032;signing&#0032;flows&#0032;in&#0032;your&#0032;product | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/documenso-platform-plan?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-documenso&#0045;platform&#0045;plan" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=670576&theme=light" alt="Documenso&#0032;Platform&#0032;Plan - Whitelabeled&#0032;signing&#0032;flows&#0032;in&#0032;your&#0032;product | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>

View File

@ -14,4 +14,4 @@
"public-api": "Public API", "public-api": "Public API",
"embedding": "Embedding", "embedding": "Embedding",
"webhooks": "Webhooks" "webhooks": "Webhooks"
} }

View File

@ -6,5 +6,6 @@
"solid": "Solid Integration", "solid": "Solid Integration",
"preact": "Preact Integration", "preact": "Preact Integration",
"angular": "Angular Integration", "angular": "Angular Integration",
"css-variables": "CSS Variables" "css-variables": "CSS Variables",
"web-components": "Web Components"
} }

View File

@ -73,14 +73,15 @@ These customization options are available for both Direct Templates and Signing
We support embedding across a range of popular JavaScript frameworks, including: We support embedding across a range of popular JavaScript frameworks, including:
| Framework | Package | | Framework | Package |
| --------- | ---------------------------------------------------------------------------------- | | --------- | ---------------------------------------------------------------------------------- |
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) | | React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) | | Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) | | Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) | | Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) | | Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
| Angular | [@documenso/embed-angular](https://www.npmjs.com/package/@documenso/embed-angular) | | Angular | [@documenso/embed-angular](https://www.npmjs.com/package/@documenso/embed-angular) |
| Web Components | [@documenso/embed-webcomponent](https://www.npmjs.com/package/@documenso/embed-webcomponent) |
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. 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.
@ -166,6 +167,7 @@ Once you've obtained the appropriate tokens, you can integrate the signing exper
- [Svelte](/developers/embedding/svelte) - [Svelte](/developers/embedding/svelte)
- [Solid](/developers/embedding/solid) - [Solid](/developers/embedding/solid)
- [Angular](/developers/embedding/angular) - [Angular](/developers/embedding/angular)
- [Web Components](/developers/embedding/web-components)
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. 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.
@ -177,4 +179,5 @@ If you're using **web components**, the integration process is slightly differen
- [Solid Integration](/developers/embedding/solid) - [Solid Integration](/developers/embedding/solid)
- [Preact Integration](/developers/embedding/preact) - [Preact Integration](/developers/embedding/preact)
- [Angular Integration](/developers/embedding/angular) - [Angular Integration](/developers/embedding/angular)
- [Web Components](/developers/embedding/web-components)
- [CSS Variables](/developers/embedding/css-variables) - [CSS Variables](/developers/embedding/css-variables)

View File

@ -0,0 +1,89 @@
---
title: Web Components Integration
description: Learn how to use our embedding SDK via Web Components on a framework-less web application.
---
# Web Components Integration
Our Web Components SDK provides a simple way to embed a signing experience within your framework-less web application. It supports both direct link templates and signing tokens.
## Installation
To install the SDK, run the following command:
```bash
npm install @documenso/embed-webcomponent
```
Then in your html file, add the following to add the script, replacing the path with the proper path to the web component script.
```html
<script src="YOUR_PATH_HERE"></script>
```
## 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 `documenso-embed-direct-template` tag.
```html
<documenso-embed-direct-template
token="YOUR_TOKEN_HERE"
</documenso-embed-direct-template>
```
#### Attributes
| Attribute | 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 |
| 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 is signed |
| onFieldUnsigned | function (optional) | A callback function that will be called when a field is unsigned |
### Signing Token
If you have a signing token, you can provide it to the `documenso-embed-sign-document` tag.
```html
<documenso-embed-sign-document
token="YOUR_TOKEN_HERE"
</documenso-embed-sign-document>
```
#### Attributes
| Attribute | 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 |
### Creating via JavaScript
You can also create the tag element using javascript, for dynamic generation of either modes. For example, this would add the sign document embed to the DOM.
```javascript
document.getElementById('my-wrapper-here').innerHTML = '';
const tag = document.createElement('documenso-embed-sign-document');
tag.setAttribute('token', data.token);
tag.style.width = '100%';
tag.style.height = '100%';
document.getElementById('my-wrapper-here').appendChild(tag);
```

View File

@ -21,14 +21,25 @@ Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) f
## API V2 - Beta ## API V2 - Beta
Our new API V2 is currently in Beta. The new API features typed SDKs for TypeScript, Python and Go and example code for many more. <Callout type="warning">API V2 is currently beta, and will be subject to breaking changes</Callout>
<Callout type="warning"> Check out the [API V2 documentation](https://documen.so/api-v2-docs) for details about the API endpoints, request parameters, response formats, and authentication methods.
NOW IN BETA: [API V2 Documentation](https://documen.so/api-v2-docs)
Our new API V2 supports the following typed SDKs:
- [TypeScript](https://github.com/documenso/sdk-typescript)
- [Python](https://github.com/documenso/sdk-python)
- [Go](https://github.com/documenso/sdk-go)
<Callout type="info">
For the staging API, please use the following base URL:
`https://stg-app.documenso.dev/api/v2-beta/`
</Callout> </Callout>
🚀 [V2 Announcement](https://documen.so/sdk-blog) 🚀 [V2 Announcement](https://documen.so/sdk-blog)
📖 [Documentation](https://documen.so/api-v2-docs)
💬 [Leave Feedback](https://documen.so/sdk-feedback) 💬 [Leave Feedback](https://documen.so/sdk-feedback)
🔔 [Breaking Changes](https://documen.so/sdk-breaking) 🔔 [Breaking Changes](https://documen.so/sdk-breaking)

View File

@ -85,12 +85,13 @@ You can also set the recipient's role, which determines their actions and permis
Documenso has 4 roles for recipients with different permissions and actions. Documenso has 4 roles for recipients with different permissions and actions.
| Role | Function | Action required | Signature | | Role | Function | Action required | Signature |
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: | | :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes | | Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional | | Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
| Viewer | Needs to confirm they viewed the document. | Yes | No | | Viewer | Needs to confirm they viewed the document. | Yes | No |
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No | | Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
### Fields ### Fields

View File

@ -1,6 +1,6 @@
{ {
"name": "@documenso/web", "name": "@documenso/web",
"version": "1.9.1-rc.1", "version": "1.9.1-rc.7",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {

View File

@ -73,7 +73,7 @@ export const EditDocumentForm = ({
const { recipients, fields } = document; const { recipients, fields } = document;
const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({ const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => { onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData( utils.document.getDocumentWithDetailsById.setData(
@ -85,6 +85,19 @@ export const EditDocumentForm = ({
}, },
}); });
const { mutateAsync: setSigningOrderForDocument } =
trpc.document.setSigningOrderForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData, id: Number(newData.id) }),
);
},
});
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({ const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: ({ fields: newFields }) => { onSuccess: ({ fields: newFields }) => {
@ -203,12 +216,9 @@ export const EditDocumentForm = ({
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => { const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try { try {
await Promise.all([ await Promise.all([
updateDocument({ setSigningOrderForDocument({
documentId: document.id, documentId: document.id,
meta: { signingOrder: data.signingOrder,
signingOrder: data.signingOrder,
modifyNextSigner: data.modifyNextSigner,
},
}), }),
setRecipients({ setRecipients({
@ -381,7 +391,6 @@ export const EditDocumentForm = ({
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
recipients={recipients} recipients={recipients}
signingOrder={document.documentMeta?.signingOrder} signingOrder={document.documentMeta?.signingOrder}
modifyNextSigner={document.documentMeta?.modifyNextSigner}
fields={fields} fields={fields}
isDocumentEnterprise={isDocumentEnterprise} isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit} onSubmit={onAddSignersFormSubmit}

View File

@ -76,7 +76,6 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
? { ? {
...templateMeta, ...templateMeta,
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL, signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
modifyNextSigner: templateMeta.modifyNextSigner ?? false,
documentId: 0, documentId: 0,
} }
: undefined; : undefined;

View File

@ -44,7 +44,12 @@ export const CheckboxField = ({ field, onSignField, onUnsignField }: CheckboxFie
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta); const parsedFieldMeta = ZCheckboxFieldMeta.parse(
field.fieldMeta ?? {
type: 'checkbox',
values: [{ id: 1, checked: false, value: '' }],
},
);
const values = parsedFieldMeta.values?.map((item) => ({ const values = parsedFieldMeta.values?.map((item) => ({
...item, ...item,

View File

@ -4,7 +4,6 @@ import { notFound } from 'next/navigation';
import { Trans, msg } from '@lingui/macro'; import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { CheckCircle2, Clock8 } from 'lucide-react'; import { CheckCircle2, Clock8 } from 'lucide-react';
import { getServerSession } from 'next-auth';
import { env } from 'next-runtime-env'; import { env } from 'next-runtime-env';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -16,10 +15,12 @@ import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-re
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getNextInboxDocument } from '@documenso/lib/server-only/user/get-next-inbox-document';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client'; import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { NextInboxItemButton } from '@documenso/ui/components/document/next-inbox-item-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
@ -61,9 +62,10 @@ export default async function CompletedSigningPage({
const { documentData } = document; const { documentData } = document;
const [fields, recipient] = await Promise.all([ const [fields, recipient, nextInboxDocument] = await Promise.all([
getFieldsForToken({ token }), getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null), getRecipientByToken({ token }).catch(() => null),
getNextInboxDocument({ email: user?.email }).catch(() => null),
]); ]);
if (!recipient) { if (!recipient) {
@ -91,8 +93,7 @@ export default async function CompletedSigningPage({
fields.find((field) => field.type === FieldType.NAME)?.customText || fields.find((field) => field.type === FieldType.NAME)?.customText ||
recipient.email; recipient.email;
const sessionData = await getServerSession(); const isLoggedIn = !!user;
const isLoggedIn = !!sessionData?.user;
const canSignUp = !isExistingUser && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true'; const canSignUp = !isExistingUser && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
return ( return (
@ -182,12 +183,16 @@ export default async function CompletedSigningPage({
</p> </p>
))} ))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4"> <div
className={cn('mt-8 flex w-full items-center justify-center gap-4', {
'max-w-sm': !nextInboxDocument,
})}
>
<DocumentShareButton documentId={document.id} token={recipient.token} /> <DocumentShareButton documentId={document.id} token={recipient.token} />
{document.status === DocumentStatus.COMPLETED ? ( {document.status === DocumentStatus.COMPLETED ? (
<DocumentDownloadButton <DocumentDownloadButton
className="flex-1" className="flex-1 text-xs"
fileName={document.title} fileName={document.title}
documentData={documentData} documentData={documentData}
disabled={document.status !== DocumentStatus.COMPLETED} disabled={document.status !== DocumentStatus.COMPLETED}
@ -199,6 +204,15 @@ export default async function CompletedSigningPage({
documentData={documentData} documentData={documentData}
/> />
)} )}
{isLoggedIn && nextInboxDocument && (
<NextInboxItemButton
className="text-xs"
userEmail={user?.email}
documentData={documentData}
nextInboxDocument={nextInboxDocument}
/>
)}
</div> </div>
</div> </div>
@ -220,7 +234,7 @@ export default async function CompletedSigningPage({
)} )}
{isLoggedIn && ( {isLoggedIn && (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-2"> <Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-4">
<Trans>Go Back Home</Trans> <Trans>Go Back Home</Trans>
</Link> </Link>
)} )}

View File

@ -15,12 +15,7 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { import { type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
DocumentSigningOrder,
type Field,
FieldType,
RecipientRole,
} from '@documenso/prisma/client';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
@ -45,12 +40,6 @@ export type SigningFormProps = {
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[]; allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void; setSelectedSignerId?: (id: number | null) => void;
isLastRecipient: boolean;
};
type SigningFormData = {
email?: string;
name?: string;
}; };
export const SigningForm = ({ export const SigningForm = ({
@ -61,7 +50,6 @@ export const SigningForm = ({
isRecipientsTurn, isRecipientsTurn,
allRecipients = [], allRecipients = [],
setSelectedSignerId, setSelectedSignerId,
isLastRecipient,
}: SigningFormProps) => { }: SigningFormProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
@ -89,7 +77,7 @@ export const SigningForm = ({
}, },
}); });
const { handleSubmit, formState } = useForm<SigningFormData>(); const { handleSubmit, formState } = useForm();
// Keep the loading state going if successful since the redirect may take some time. // Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful; const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
@ -114,58 +102,20 @@ export const SigningForm = ({
validateFieldsInserted(fieldsRequiringValidation); validateFieldsInserted(fieldsRequiringValidation);
}; };
const completeDocument = async ( const onFormSubmit = async () => {
authOptions?: TRecipientActionAuth, setValidateUninsertedFields(true);
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token,
documentId: document.id,
authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocumentWithToken(payload); const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
analytics.capture('App: Recipient has completed signing', { if (hasSignatureField && !signatureValid) {
signerId: recipient.id, return;
documentId: document.id,
timestamp: new Date().toISOString(),
});
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
};
const onFormSubmit = async (data: SigningFormData) => {
try {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
if (hasSignatureField && !signatureValid) {
throw new Error('Please provide a valid signature');
}
if (!isFieldsValid) {
throw new Error('Please complete all required fields');
}
const nextSigner =
data.email && data.name
? {
email: data.email,
name: data.name,
}
: undefined;
await completeDocument(undefined, nextSigner);
} catch (error) {
toast({
title: 'Error',
description: 'An error occurred while completing the document. Please try again.',
variant: 'destructive',
});
} }
if (!isFieldsValid) {
return;
}
await completeDocument();
}; };
const onAssistantFormSubmit = () => { const onAssistantFormSubmit = () => {
@ -193,6 +143,22 @@ export const SigningForm = ({
} }
}; };
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
await completeDocumentWithToken({
token: recipient.token,
documentId: document.id,
authOptions,
});
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
};
return ( return (
<div <div
className={cn( className={cn(
@ -242,21 +208,12 @@ export const SigningForm = ({
<SignDialog <SignDialog
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onSignatureComplete={async (nextSigner) => { onSignatureComplete={handleSubmit(onFormSubmit)}
await handleSubmit(async (formData) =>
onFormSubmit({ ...formData, ...nextSigner }),
)();
}}
documentTitle={document.title} documentTitle={document.title}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated} fieldsValidated={fieldsValidated}
role={recipient.role} role={recipient.role}
disabled={!isRecipientsTurn} disabled={!isRecipientsTurn}
canModifyNextSigner={
document.documentMeta?.modifyNextSigner &&
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
!isLastRecipient
}
/> />
</div> </div>
</div> </div>
@ -426,21 +383,12 @@ export const SigningForm = ({
<SignDialog <SignDialog
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onSignatureComplete={async (nextSigner) => { onSignatureComplete={handleSubmit(onFormSubmit)}
await handleSubmit(async (formData) =>
onFormSubmit({ ...formData, ...nextSigner }),
)();
}}
documentTitle={document.title} documentTitle={document.title}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated} fieldsValidated={fieldsValidated}
role={recipient.role} role={recipient.role}
disabled={!isRecipientsTurn} disabled={!isRecipientsTurn}
canModifyNextSigner={
document.documentMeta?.modifyNextSigner &&
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
!isLastRecipient
}
/> />
</div> </div>
</fieldset> </fieldset>

View File

@ -9,7 +9,6 @@ import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-re
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token'; import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getIsLastRecipient } from '@documenso/lib/server-only/recipient/get-is-last-recipient';
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn'; import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
@ -45,7 +44,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, recipient, fields, completedFields, isLastRecipient] = await Promise.all([ const [document, recipient, fields, completedFields] = await Promise.all([
getDocumentAndSenderByToken({ getDocumentAndSenderByToken({
token, token,
userId: user?.id, userId: user?.id,
@ -54,7 +53,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
getRecipientByToken({ token }).catch(() => null), getRecipientByToken({ token }).catch(() => null),
getFieldsForToken({ token }), getFieldsForToken({ token }),
getCompletedFieldsForToken({ token }), getCompletedFieldsForToken({ token }),
getIsLastRecipient({ token }),
]); ]);
if ( if (
@ -171,7 +169,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
completedFields={completedFields} completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn} isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients} allRecipients={allRecipients}
isLastRecipient={isLastRecipient}
/> />
</DocumentAuthProvider> </DocumentAuthProvider>
</SigningProvider> </SigningProvider>

View File

@ -37,6 +37,11 @@ export const RecipientProvider = ({
recipient, recipient,
targetSigner = null, targetSigner = null,
}: RecipientProviderProps) => { }: RecipientProviderProps) => {
// console.log({
// recipient,
// targetSigner,
// isAssistantMode: !!targetSigner,
// });
return ( return (
<RecipientContext.Provider <RecipientContext.Provider
value={{ value={{

View File

@ -43,9 +43,10 @@ type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
export interface RejectDocumentDialogProps { export interface RejectDocumentDialogProps {
document: Pick<Document, 'id'>; document: Pick<Document, 'id'>;
token: string; token: string;
onRejected?: (reason: string) => void | Promise<void>;
} }
export function RejectDocumentDialog({ document, token }: RejectDocumentDialogProps) { export function RejectDocumentDialog({ document, token, onRejected }: RejectDocumentDialogProps) {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -79,7 +80,11 @@ export function RejectDocumentDialog({ document, token }: RejectDocumentDialogPr
setIsOpen(false); setIsOpen(false);
router.push(`/sign/${token}/rejected`); if (onRejected) {
await onRejected(reason);
} else {
router.push(`/sign/${token}/rejected`);
}
} catch (err) { } catch (err) {
toast({ toast({
title: 'Error', title: 'Error',

View File

@ -1,36 +1,18 @@
'use client';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { ArrowRight } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import type { Field } from '@documenso/prisma/client'; import type { Field } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client'; import { RecipientRole } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { SigningDisclosure } from '~/components/general/signing-disclosure'; import { SigningDisclosure } from '~/components/general/signing-disclosure';
@ -39,26 +21,12 @@ export type SignDialogProps = {
documentTitle: string; documentTitle: string;
fields: Field[]; fields: Field[];
fieldsValidated: () => void | Promise<void>; fieldsValidated: () => void | Promise<void>;
onSignatureComplete: (nextSigner?: { email?: string; name?: string }) => void | Promise<void>; onSignatureComplete: () => void | Promise<void>;
role: RecipientRole; role: RecipientRole;
disabled?: boolean; disabled?: boolean;
canModifyNextSigner?: boolean;
}; };
const formSchema = z.object({ export const SignDialog = ({
modifyNextSigner: z.boolean().default(false),
nextSigner: z
.object({
email: z.string().email({ message: 'Please enter a valid email address' }).optional(),
name: z.string().optional(),
})
.optional()
.default({}),
});
type TFormSchema = z.infer<typeof formSchema>;
export function SignDialog({
isSubmitting, isSubmitting,
documentTitle, documentTitle,
fields, fields,
@ -66,9 +34,7 @@ export function SignDialog({
onSignatureComplete, onSignatureComplete,
role, role,
disabled = false, disabled = false,
canModifyNextSigner = false, }: SignDialogProps) => {
}: SignDialogProps) {
const [step, setStep] = useState(1);
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]); const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
@ -81,336 +47,104 @@ export function SignDialog({
setShowDialog(open); setShowDialog(open);
}; };
const totalSteps = 2;
const handleContinue = () => {
if (step < totalSteps) {
setStep(step + 1);
}
};
const form = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
});
const onFormSubmit = async (data: TFormSchema) => {
try {
await fieldsValidated();
await onSignatureComplete({
email: data.nextSigner.email?.trim().toLowerCase(),
name: data.nextSigner.name?.trim(),
});
setShowDialog(false);
form.reset();
} catch (err) {
console.error(err);
}
};
return ( return (
<> <Dialog open={showDialog} onOpenChange={handleOpenChange}>
{!canModifyNextSigner ? ( <DialogTrigger asChild>
<Dialog open={showDialog} onOpenChange={handleOpenChange}> <Button
<DialogTrigger asChild> className="w-full"
<Button type="button"
className="w-full" size="lg"
type="button" onClick={fieldsValidated}
size="lg" loading={isSubmitting}
onClick={fieldsValidated} disabled={disabled}
loading={isSubmitting}
disabled={disabled}
>
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{role === RecipientRole.VIEWER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.SIGNER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.APPROVER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
)}
</div>
<SigningDisclosure className="mt-4" />
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowDialog(false);
}}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
className="flex-1"
disabled={!isComplete}
loading={isSubmitting}
onClick={async (e) => {
e.preventDefault();
await onSignatureComplete();
}}
>
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
<Dialog
onOpenChange={(open) => {
if (open) setStep(1);
}}
> >
<DialogTrigger asChild> {isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{role === RecipientRole.VIEWER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.SIGNER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.APPROVER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
)}
</div>
<SigningDisclosure className="mt-4" />
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button <Button
className="w-full"
type="button" type="button"
size="lg" className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
onClick={fieldsValidated} variant="secondary"
loading={isSubmitting} onClick={() => {
disabled={disabled} setShowDialog(false);
}}
> >
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>} <Trans>Cancel</Trans>
</Button> </Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
{step === 1 && (
<div className="text-foreground text-base font-semibold">
<Trans>
Modify Next Signer <span className="text-muted-foreground">(Optional)</span>
</Trans>
</div>
)}
{step === 2 && ( <Button
<div className="text-foreground text-xl font-semibold"> type="button"
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>} className="flex-1"
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>} disabled={!isComplete}
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>} loading={isSubmitting}
</div> onClick={onSignatureComplete}
)} >
</DialogTitle> {role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
{step === 1 && ( {role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
<Form {...form}> </Button>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="flex flex-col gap-y-4"> </div>
<FormField </DialogFooter>
control={form.control} </DialogContent>
name="modifyNextSigner" </Dialog>
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="font-normal">
<Trans>Modify next signer details</Trans>
</FormLabel>
</FormItem>
)}
/>
{form.watch('modifyNextSigner') && (
<>
<FormField
control={form.control}
name="nextSigner.email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Next Signer Email</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nextSigner.name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Next Signer Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
)}
{step === 2 && (
<>
<div className="text-muted-foreground max-w-[50ch]">
{role === RecipientRole.VIEWER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.SIGNER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.APPROVER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
)}
</div>
<SigningDisclosure className="mt-4" />
</>
)}
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div className="flex justify-center space-x-1.5 max-sm:order-1">
{[...Array(totalSteps)].map((_, index) => (
<button
key={index}
onClick={() => setStep(index + 1)}
className={cn(
'bg-primary h-1.5 w-1.5 rounded-full',
index + 1 === step ? 'bg-primary' : 'opacity-20',
)}
type="button"
aria-label={`Go to step ${index + 1}`}
/>
))}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost">
Cancel
</Button>
</DialogClose>
{step === 1 && (
<Button className="group" type="button" onClick={handleContinue}>
Next
<ArrowRight
className="-me-1 ms-2 opacity-60 transition-transform group-hover:translate-x-0.5"
size={16}
strokeWidth={2}
aria-hidden="true"
/>
</Button>
)}
{step === 2 && (
<Button
type="button"
className="flex-1"
disabled={!isComplete}
loading={isSubmitting}
onClick={form.handleSubmit(onFormSubmit)}
>
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
</Button>
)}
</DialogFooter>
</div>
</DialogContent>
</Dialog>
)}
</>
); );
} };

View File

@ -182,6 +182,23 @@ export const SigningFieldContainer = ({
</button> </button>
)} )}
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
{
'bg-foreground/5 border-border border': !field.inserted,
},
{
'bg-documenso-200 border-primary border': field.inserted,
},
)}
>
{field.fieldMeta.label}
</div>
)}
{children} {children}
</FieldRootContainer> </FieldRootContainer>
</div> </div>

View File

@ -49,7 +49,6 @@ export type SigningPageViewProps = {
completedFields: CompletedField[]; completedFields: CompletedField[];
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[]; allRecipients?: RecipientWithFields[];
isLastRecipient: boolean;
}; };
export const SigningPageView = ({ export const SigningPageView = ({
@ -59,7 +58,6 @@ export const SigningPageView = ({
completedFields, completedFields,
isRecipientsTurn, isRecipientsTurn,
allRecipients = [], allRecipients = [],
isLastRecipient,
}: SigningPageViewProps) => { }: SigningPageViewProps) => {
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
@ -161,7 +159,6 @@ export const SigningPageView = ({
redirectUrl={documentMeta?.redirectUrl} redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn} isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients} allRecipients={allRecipients}
isLastRecipient={isLastRecipient}
setSelectedSignerId={setSelectedSignerId} setSelectedSignerId={setSelectedSignerId}
/> />
</div> </div>

View File

@ -10,6 +10,7 @@ export type EmbedDocumentCompletedPageProps = {
}; };
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => { export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
console.log({ signature });
return ( return (
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6"> <div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-2xl font-semibold"> <h3 className="text-foreground text-2xl font-semibold">

View File

@ -49,7 +49,7 @@ export type EmbedDirectTemplateClientPageProps = {
fields: Field[]; fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null; metadata?: DocumentMeta | TemplateMeta | null;
hidePoweredBy?: boolean; hidePoweredBy?: boolean;
isPlatformOrEnterprise?: boolean; allowWhiteLabelling?: boolean;
}; };
export const EmbedDirectTemplateClientPage = ({ export const EmbedDirectTemplateClientPage = ({
@ -60,7 +60,7 @@ export const EmbedDirectTemplateClientPage = ({
fields, fields,
metadata, metadata,
hidePoweredBy = false, hidePoweredBy = false,
isPlatformOrEnterprise = false, allowWhiteLabelling = false,
}: EmbedDirectTemplateClientPageProps) => { }: EmbedDirectTemplateClientPageProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
@ -288,7 +288,7 @@ export const EmbedDirectTemplateClientPage = ({
document.documentElement.classList.add('dark-mode-disabled'); document.documentElement.classList.add('dark-mode-disabled');
} }
if (isPlatformOrEnterprise) { if (allowWhiteLabelling) {
injectCss({ injectCss({
css: data.css, css: data.css,
cssVars: data.cssVars, cssVars: data.cssVars,
@ -349,7 +349,7 @@ export const EmbedDirectTemplateClientPage = ({
{/* Widget */} {/* Widget */}
<div <div
key={isExpanded ? 'expanded' : 'collapsed'} key={isExpanded ? 'expanded' : 'collapsed'}
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0" className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined} data-expanded={isExpanded || undefined}
> >
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6"> <div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
@ -360,19 +360,34 @@ export const EmbedDirectTemplateClientPage = ({
<Trans>Sign document</Trans> <Trans>Sign document</Trans>
</h3> </h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden"> {isExpanded ? (
{isExpanded ? ( <Button
<LucideChevronDown variant="outline"
className="text-muted-foreground h-5 w-5" className="h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)} onClick={() => setIsExpanded(false)}
/> >
) : ( <LucideChevronDown className="text-muted-foreground h-5 w-5" />
<LucideChevronUp </Button>
className="text-muted-foreground h-5 w-5" ) : pendingFields.length > 0 ? (
onClick={() => setIsExpanded(true)} <Button
/> variant="outline"
)} className="h-8 w-8 p-0 md:hidden"
</Button> onClick={() => setIsExpanded(true)}
>
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
</Button>
) : (
<Button
variant="default"
size="sm"
className="md:hidden"
disabled={isThrottled || (hasSignatureField && !signatureValid)}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import { notFound } from 'next/navigation';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform'; import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@ -55,12 +56,16 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
documentAuth: template.authOptions, documentAuth: template.authOptions,
}); });
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([ const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([
isDocumentPlatform(template), isDocumentPlatform(template),
isUserEnterprise({ isUserEnterprise({
userId: template.userId, userId: template.userId,
teamId: template.teamId ?? undefined, teamId: template.teamId ?? undefined,
}), }),
isUserCommunityPlan({
userId: template.userId,
teamId: template.teamId ?? undefined,
}),
]); ]);
const isAccessAuthValid = match(derivedRecipientAccessAuth) const isAccessAuthValid = match(derivedRecipientAccessAuth)
@ -105,8 +110,10 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
recipient={recipient} recipient={recipient}
fields={fields} fields={fields}
metadata={template.templateMeta} metadata={template.templateMeta}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy} hidePoweredBy={
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument} isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
}
allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
/> />
</RecipientProvider> </RecipientProvider>
</DocumentAuthProvider> </DocumentAuthProvider>

View File

@ -0,0 +1,40 @@
import { Trans } from '@lingui/macro';
import { XCircle } from 'lucide-react';
import type { Signature } from '@documenso/prisma/client';
export type EmbedDocumentRejectedPageProps = {
name?: string;
signature?: Signature;
};
export const EmbedDocumentRejected = ({ name }: EmbedDocumentRejectedPageProps) => {
return (
<div className="embed--DocumentRejected relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<XCircle className="text-destructive h-10 w-10" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Rejected</Trans>
</h2>
</div>
<div className="text-destructive mt-4 flex items-center text-center text-sm">
<Trans>You have rejected this document</Trans>
</div>
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
<Trans>
The document owner has been notified of your decision. They may contact you with further
instructions if necessary.
</Trans>
</p>
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
<Trans>No further action is required from you at this time.</Trans>
</p>
</div>
</div>
);
};

View File

@ -10,7 +10,13 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client'; import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
import { type DocumentData, type Field, FieldType, RecipientRole } from '@documenso/prisma/client'; import {
type DocumentData,
type Field,
FieldType,
RecipientRole,
SigningStatus,
} from '@documenso/prisma/client';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
@ -26,11 +32,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context'; import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
import { RejectDocumentDialog } from '~/app/(signing)/sign/[token]/reject-document-dialog';
import { Logo } from '~/components/branding/logo'; import { Logo } from '~/components/branding/logo';
import { EmbedClientLoading } from '../../client-loading'; import { EmbedClientLoading } from '../../client-loading';
import { EmbedDocumentCompleted } from '../../completed'; import { EmbedDocumentCompleted } from '../../completed';
import { EmbedDocumentFields } from '../../document-fields'; import { EmbedDocumentFields } from '../../document-fields';
import { EmbedDocumentRejected } from '../../rejected';
import { injectCss } from '../../util'; import { injectCss } from '../../util';
import { ZSignDocumentEmbedDataSchema } from './schema'; import { ZSignDocumentEmbedDataSchema } from './schema';
@ -43,7 +51,7 @@ export type EmbedSignDocumentClientPageProps = {
metadata?: DocumentMeta | TemplateMeta | null; metadata?: DocumentMeta | TemplateMeta | null;
isCompleted?: boolean; isCompleted?: boolean;
hidePoweredBy?: boolean; hidePoweredBy?: boolean;
isPlatformOrEnterprise?: boolean; allowWhitelabelling?: boolean;
allRecipients?: RecipientWithFields[]; allRecipients?: RecipientWithFields[];
}; };
@ -56,7 +64,7 @@ export const EmbedSignDocumentClientPage = ({
metadata, metadata,
isCompleted, isCompleted,
hidePoweredBy = false, hidePoweredBy = false,
isPlatformOrEnterprise = false, allowWhitelabelling = false,
allRecipients = [], allRecipients = [],
}: EmbedSignDocumentClientPageProps) => { }: EmbedSignDocumentClientPageProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
@ -75,6 +83,9 @@ export const EmbedSignDocumentClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted); const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [hasRejectedDocument, setHasRejectedDocument] = useState(
recipient.signingStatus === SigningStatus.REJECTED,
);
const [selectedSignerId, setSelectedSignerId] = useState<number | null>( const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
allRecipients.length > 0 ? allRecipients[0].id : null, allRecipients.length > 0 ? allRecipients[0].id : null,
); );
@ -83,6 +94,8 @@ export const EmbedSignDocumentClientPage = ({
const [isNameLocked, setIsNameLocked] = useState(false); const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId); const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT; const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
@ -161,6 +174,25 @@ export const EmbedSignDocumentClientPage = ({
} }
}; };
const onDocumentRejected = (reason: string) => {
if (window.parent) {
window.parent.postMessage(
{
action: 'document-rejected',
data: {
token,
documentId,
recipientId: recipient.id,
reason,
},
},
'*',
);
}
setHasRejectedDocument(true);
};
useLayoutEffect(() => { useLayoutEffect(() => {
const hash = window.location.hash.slice(1); const hash = window.location.hash.slice(1);
@ -174,12 +206,13 @@ export const EmbedSignDocumentClientPage = ({
// Since a recipient can be provided a name we can lock it without requiring // Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates. // a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName); setIsNameLocked(!!data.lockName);
setAllowDocumentRejection(!!data.allowDocumentRejection);
if (data.darkModeDisabled) { if (data.darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled'); document.documentElement.classList.add('dark-mode-disabled');
} }
if (isPlatformOrEnterprise) { if (allowWhitelabelling) {
injectCss({ injectCss({
css: data.css, css: data.css,
cssVars: data.cssVars, cssVars: data.cssVars,
@ -208,6 +241,10 @@ export const EmbedSignDocumentClientPage = ({
} }
}, [hasFinishedInit, hasDocumentLoaded]); }, [hasFinishedInit, hasDocumentLoaded]);
if (hasRejectedDocument) {
return <EmbedDocumentRejected name={fullName} />;
}
if (hasCompletedDocument) { if (hasCompletedDocument) {
return ( return (
<EmbedDocumentCompleted <EmbedDocumentCompleted
@ -229,6 +266,16 @@ export const EmbedSignDocumentClientPage = ({
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6"> <div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />} {(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
{allowDocumentRejection && (
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<RejectDocumentDialog
document={{ id: documentId }}
token={token}
onRejected={onDocumentRejected}
/>
</div>
)}
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row"> <div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}
<div className="embed--DocumentViewer flex-1"> <div className="embed--DocumentViewer flex-1">
@ -241,7 +288,7 @@ export const EmbedSignDocumentClientPage = ({
{/* Widget */} {/* Widget */}
<div <div
key={isExpanded ? 'expanded' : 'collapsed'} key={isExpanded ? 'expanded' : 'collapsed'}
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0" className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined} data-expanded={isExpanded || undefined}
> >
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6"> <div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
@ -256,19 +303,36 @@ export const EmbedSignDocumentClientPage = ({
)} )}
</h3> </h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden"> {isExpanded ? (
{isExpanded ? ( <Button
<LucideChevronDown variant="outline"
className="text-muted-foreground h-5 w-5" className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)} onClick={() => setIsExpanded(false)}
/> >
) : ( <LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
<LucideChevronUp </Button>
className="text-muted-foreground h-5 w-5" ) : pendingFields.length > 0 ? (
onClick={() => setIsExpanded(true)} <Button
/> variant="outline"
)} className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
</Button> onClick={() => setIsExpanded(true)}
>
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
</Button>
) : (
<Button
variant="default"
size="sm"
className="md:hidden"
disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
</div> </div>
</div> </div>
@ -420,7 +484,7 @@ export const EmbedSignDocumentClientPage = ({
</Button> </Button>
) : ( ) : (
<Button <Button
className="col-start-2" className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
disabled={ disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid) isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
} }

View File

@ -2,6 +2,7 @@ import { notFound } from 'next/navigation';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform'; import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@ -62,12 +63,16 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
return <EmbedPaywall />; return <EmbedPaywall />;
} }
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([ const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([
isDocumentPlatform(document), isDocumentPlatform(document),
isUserEnterprise({ isUserEnterprise({
userId: document.userId, userId: document.userId,
teamId: document.teamId ?? undefined, teamId: document.teamId ?? undefined,
}), }),
isUserCommunityPlan({
userId: document.userId,
teamId: document.teamId ?? undefined,
}),
]); ]);
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
@ -126,8 +131,10 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
fields={fields} fields={fields}
metadata={document.documentMeta} metadata={document.documentMeta}
isCompleted={document.status === DocumentStatus.COMPLETED} isCompleted={document.status === DocumentStatus.COMPLETED}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy} hidePoweredBy={
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument} isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
}
allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
allRecipients={allRecipients} allRecipients={allRecipients}
/> />
</DocumentAuthProvider> </DocumentAuthProvider>

View File

@ -13,4 +13,5 @@ export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
.optional() .optional()
.transform((value) => value || undefined), .transform((value) => value || undefined),
lockName: z.boolean().optional().default(false), lockName: z.boolean().optional().default(false),
allowDocumentRejection: z.boolean().optional(),
}); });

11937
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"version": "1.9.1-rc.1", "version": "1.9.1-rc.7",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web", "build:web": "turbo run build --filter=@documenso/web",

View File

@ -4,15 +4,14 @@ import { stripe } from '@documenso/lib/server-only/stripe';
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE]; type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => { export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
const planTypes = typeof plan === 'string' ? [plan] : plan; const planTypes: string[] = typeof plan === 'string' ? [plan] : plan;
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR '); const prices = await stripe.prices.list({
const { data: prices } = await stripe.prices.search({
query,
expand: ['data.product'], expand: ['data.product'],
limit: 100, limit: 100,
}); });
return prices.filter((price) => price.type === 'recurring'); return prices.data.filter(
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
);
}; };

View File

@ -0,0 +1,56 @@
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import type { Subscription } from '@documenso/prisma/client';
import { getCommunityPlanPriceIds } from '../stripe/get-community-plan-prices';
export type IsCommunityPlanOptions = {
userId: number;
teamId?: number;
};
/**
* Whether the user or team is on the community plan.
*/
export const isCommunityPlan = async ({
userId,
teamId,
}: IsCommunityPlanOptions): Promise<boolean> => {
let subscriptions: Subscription[] = [];
if (teamId) {
subscriptions = await prisma.team
.findFirstOrThrow({
where: {
id: teamId,
},
select: {
owner: {
include: {
subscriptions: true,
},
},
},
})
.then((team) => team.owner.subscriptions);
} else {
subscriptions = await prisma.user
.findFirstOrThrow({
where: {
id: userId,
},
select: {
subscriptions: true,
},
})
.then((user) => user.subscriptions);
}
if (subscriptions.length === 0) {
return false;
}
const communityPlanPriceIds = await getCommunityPlanPriceIds();
return subscriptionsContainsActivePlan(subscriptions, communityPlanPriceIds);
};

View File

@ -28,7 +28,6 @@ export type CreateDocumentMetaOptions = {
distributionMethod?: DocumentDistributionMethod; distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean; typedSignatureEnabled?: boolean;
language?: SupportedLanguageCodes; language?: SupportedLanguageCodes;
modifyNextSigner?: boolean;
requestMetadata: ApiRequestMetadata; requestMetadata: ApiRequestMetadata;
}; };
@ -47,7 +46,6 @@ export const upsertDocumentMeta = async ({
distributionMethod, distributionMethod,
typedSignatureEnabled, typedSignatureEnabled,
language, language,
modifyNextSigner,
requestMetadata, requestMetadata,
}: CreateDocumentMetaOptions) => { }: CreateDocumentMetaOptions) => {
const document = await prisma.document.findFirst({ const document = await prisma.document.findFirst({
@ -100,7 +98,6 @@ export const upsertDocumentMeta = async ({
distributionMethod, distributionMethod,
typedSignatureEnabled, typedSignatureEnabled,
language, language,
modifyNextSigner,
}, },
update: { update: {
subject, subject,
@ -114,7 +111,6 @@ export const upsertDocumentMeta = async ({
distributionMethod, distributionMethod,
typedSignatureEnabled, typedSignatureEnabled,
language, language,
modifyNextSigner,
}, },
}); });

View File

@ -12,6 +12,7 @@ import {
WebhookTriggerEvents, WebhookTriggerEvents,
} from '@documenso/prisma/client'; } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client'; import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth'; import type { TRecipientActionAuth } from '../../types/document-auth';
import { import {
@ -28,10 +29,6 @@ export type CompleteDocumentWithTokenOptions = {
userId?: number; userId?: number;
authOptions?: TRecipientActionAuth; authOptions?: TRecipientActionAuth;
requestMetadata?: RequestMetadata; requestMetadata?: RequestMetadata;
nextSigner?: {
email: string;
name: string;
};
}; };
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => { const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
@ -55,53 +52,10 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
}); });
}; };
export const delegateNextSigner = async ({
documentId,
currentRecipientId,
nextSigner,
}: {
documentId: number;
currentRecipientId: number;
nextSigner: { email: string; name: string };
}) => {
const document = await prisma.document.findUnique({
where: { id: documentId },
include: {
recipients: {
orderBy: [{ signingOrder: 'asc' }, { id: 'asc' }],
},
},
});
if (!document) {
throw new Error('Document not found');
}
const currentRecipient = document.recipients.find((r) => r.id === currentRecipientId);
const nextRecipient = document.recipients.find(
(r) => r.signingOrder === (currentRecipient?.signingOrder ?? 0) + 1,
);
if (!nextRecipient) {
throw new Error('Next recipient not found');
}
await prisma.recipient.update({
where: { id: nextRecipient.id },
data: {
email: nextSigner.email,
name: nextSigner.name,
},
});
return nextRecipient;
};
export const completeDocumentWithToken = async ({ export const completeDocumentWithToken = async ({
token, token,
documentId, documentId,
requestMetadata, requestMetadata,
nextSigner,
}: CompleteDocumentWithTokenOptions) => { }: CompleteDocumentWithTokenOptions) => {
const document = await getDocument({ token, documentId }); const document = await getDocument({ token, documentId });
@ -119,6 +73,13 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has already signed`); throw new Error(`Recipient ${recipient.id} has already signed`);
} }
if (recipient.signingStatus === SigningStatus.REJECTED) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Recipient has already rejected the document',
statusCode: 400,
});
}
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) { if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token }); const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
@ -159,18 +120,6 @@ export const completeDocumentWithToken = async ({
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values'); // throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
// } // }
if (
nextSigner &&
document.documentMeta?.modifyNextSigner &&
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL
) {
await delegateNextSigner({
documentId: document.id,
currentRecipientId: recipient.id,
nextSigner,
});
}
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.recipient.update({ await tx.recipient.update({
where: { where: {

View File

@ -88,6 +88,7 @@ export const findDocuments = async ({
const searchFilter: Prisma.DocumentWhereInput = { const searchFilter: Prisma.DocumentWhereInput = {
OR: [ OR: [
{ title: { contains: query, mode: 'insensitive' } }, { title: { contains: query, mode: 'insensitive' } },
{ externalId: { contains: query, mode: 'insensitive' } },
{ recipients: { some: { name: { contains: query, mode: 'insensitive' } } } }, { recipients: { some: { name: { contains: query, mode: 'insensitive' } } } },
{ recipients: { some: { email: { contains: query, mode: 'insensitive' } } } }, { recipients: { some: { email: { contains: query, mode: 'insensitive' } } } },
], ],

View File

@ -34,6 +34,14 @@ export const searchDocumentsWithKeyword = async ({
userId: userId, userId: userId,
deletedAt: null, deletedAt: null,
}, },
{
externalId: {
contains: query,
mode: 'insensitive',
},
userId: userId,
deletedAt: null,
},
{ {
recipients: { recipients: {
some: { some: {
@ -88,6 +96,23 @@ export const searchDocumentsWithKeyword = async ({
}, },
deletedAt: null, deletedAt: null,
}, },
{
externalId: {
contains: query,
mode: 'insensitive',
},
teamId: {
not: null,
},
team: {
members: {
some: {
userId: userId,
},
},
},
deletedAt: null,
},
], ],
}, },
include: { include: {

View File

@ -1,46 +0,0 @@
import { prisma } from '@documenso/prisma';
import { DocumentSigningOrder, RecipientRole, SigningStatus } from '@documenso/prisma/client';
export type GetIsLastRecipientOptions = {
token: string;
};
export async function getIsLastRecipient({ token }: GetIsLastRecipientOptions) {
const document = await prisma.document.findFirstOrThrow({
where: {
recipients: {
some: {
token,
},
},
},
include: {
documentMeta: true,
recipients: {
where: {
role: {
not: RecipientRole.CC,
},
},
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
},
},
});
if (document.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
const unsignedRecipients = document.recipients.filter(
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
);
return unsignedRecipients.length <= 1;
}
const { recipients } = document;
const currentRecipientIndex = recipients.findIndex((r) => r.token === token);
if (currentRecipientIndex === -1) {
return false;
}
return currentRecipientIndex === recipients.length - 1;
}

View File

@ -0,0 +1,46 @@
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
type GetNextInboxDocumentOptions = {
email: string | undefined;
};
export const getNextInboxDocument = async ({ email }: GetNextInboxDocumentOptions) => {
if (!email) {
throw new Error('User is required');
}
return await prisma.document.findMany({
where: {
recipients: {
some: {
email,
signingStatus: SigningStatus.NOT_SIGNED,
role: {
not: RecipientRole.CC,
},
},
},
status: { not: DocumentStatus.DRAFT },
deletedAt: null,
},
select: {
id: true,
createdAt: true,
title: true,
status: true,
recipients: {
where: {
email,
},
select: {
token: true,
role: true,
},
},
documentMeta: true,
},
orderBy: [{ createdAt: 'asc' }],
});
};

View File

@ -55,7 +55,6 @@ export const ZDocumentSchema = DocumentSchema.pick({
typedSignatureEnabled: true, typedSignatureEnabled: true,
language: true, language: true,
emailSettings: true, emailSettings: true,
modifyNextSigner: true,
}).nullable(), }).nullable(),
recipients: ZRecipientLiteSchema.array(), recipients: ZRecipientLiteSchema.array(),
fields: ZFieldSchema.array(), fields: ZFieldSchema.array(),

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "modifyNextSigner" BOOLEAN NOT NULL DEFAULT false;

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "TemplateMeta" ADD COLUMN "modifyNextSigner" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,18 @@
/*
Warnings:
- You are about to drop the column `expires` on the `Session` table. All the data in the column will be lost.
- Added the required column `expiresAt` to the `Session` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Session` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "password" TEXT;
-- AlterTable
ALTER TABLE "Session" DROP COLUMN "expires",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "ipAddress" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "userAgent" TEXT;

View File

@ -270,18 +270,25 @@ model Account {
scope String? scope String?
id_token String? @db.Text id_token String? @db.Text
session_state String? session_state String?
password String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId]) @@unique([provider, providerAccountId])
} }
model Session { model Session {
id String @id @default(cuid()) id String @id @default(cuid())
sessionToken String @unique sessionToken String @unique
userId Int userId Int
expires DateTime
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) ipAddress String?
userAgent String?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
enum DocumentStatus { enum DocumentStatus {
@ -390,7 +397,6 @@ model DocumentMeta {
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String? redirectUrl String?
signingOrder DocumentSigningOrder @default(PARALLEL) signingOrder DocumentSigningOrder @default(PARALLEL)
modifyNextSigner Boolean @default(false)
typedSignatureEnabled Boolean @default(true) typedSignatureEnabled Boolean @default(true)
language String @default("en") language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL) distributionMethod DocumentDistributionMethod @default(EMAIL)
@ -660,7 +666,6 @@ model TemplateMeta {
signingOrder DocumentSigningOrder? @default(PARALLEL) signingOrder DocumentSigningOrder? @default(PARALLEL)
typedSignatureEnabled Boolean @default(true) typedSignatureEnabled Boolean @default(true)
distributionMethod DocumentDistributionMethod @default(EMAIL) distributionMethod DocumentDistributionMethod @default(EMAIL)
modifyNextSigner Boolean @default(false)
templateId Int @unique templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade) template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)

View File

@ -266,14 +266,15 @@ export const documentRouter = router({
/** /**
* @public * @public
*
* Todo: Refactor to updateDocument.
*/ */
updateDocument: authenticatedProcedure setSettingsForDocument: authenticatedProcedure
.meta({ .meta({
openapi: { openapi: {
method: 'POST', method: 'POST',
path: '/document/update', path: '/document/update',
summary: 'Update document', summary: 'Update document',
description: 'Update an existing document',
tags: ['Document'], tags: ['Document'],
}, },
}) })
@ -285,9 +286,9 @@ export const documentRouter = router({
const userId = ctx.user.id; const userId = ctx.user.id;
if (Object.keys(meta).length > 0) { if (Object.values(meta).length > 0) {
await upsertDocumentMeta({ await upsertDocumentMeta({
userId, userId: ctx.user.id,
teamId, teamId,
documentId, documentId,
subject: meta.subject, subject: meta.subject,
@ -300,7 +301,6 @@ export const documentRouter = router({
distributionMethod: meta.distributionMethod, distributionMethod: meta.distributionMethod,
signingOrder: meta.signingOrder, signingOrder: meta.signingOrder,
emailSettings: meta.emailSettings, emailSettings: meta.emailSettings,
modifyNextSigner: meta.modifyNextSigner,
requestMetadata: ctx.metadata, requestMetadata: ctx.metadata,
}); });
} }

View File

@ -251,7 +251,6 @@ export const ZUpdateDocumentRequestSchema = z.object({
language: ZDocumentMetaLanguageSchema.optional(), language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(), typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(), emailSettings: ZDocumentEmailSettingsSchema.optional(),
modifyNextSigner: z.boolean().optional(),
}) })
.optional(), .optional(),
}); });

View File

@ -437,14 +437,13 @@ export const recipientRouter = router({
completeDocumentWithToken: procedure completeDocumentWithToken: procedure
.input(ZCompleteDocumentWithTokenMutationSchema) .input(ZCompleteDocumentWithTokenMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { token, documentId, authOptions, nextSigner } = input; const { token, documentId, authOptions } = input;
return await completeDocumentWithToken({ return await completeDocumentWithToken({
token, token,
documentId, documentId,
authOptions, authOptions,
userId: ctx.user?.id, userId: ctx.user?.id,
nextSigner,
requestMetadata: extractNextApiRequestMetadata(ctx.req), requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
}), }),

View File

@ -212,12 +212,6 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
token: z.string(), token: z.string(),
documentId: z.number(), documentId: z.number(),
authOptions: ZRecipientActionAuthSchema.optional(), authOptions: ZRecipientActionAuthSchema.optional(),
nextSigner: z
.object({
email: z.string().email(),
name: z.string(),
})
.optional(),
}); });
export type TCompleteDocumentWithTokenMutationSchema = z.infer< export type TCompleteDocumentWithTokenMutationSchema = z.infer<

View File

@ -0,0 +1,79 @@
import type { HTMLAttributes } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CheckCircle2, Clock, File } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils';
type FriendlyStatus = {
label: MessageDescriptor;
labelExtended: MessageDescriptor;
icon?: LucideIcon;
color: string;
};
export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
PENDING: {
label: msg`Pending`,
labelExtended: msg`Document pending`,
icon: Clock,
color: 'text-blue-600 dark:text-blue-300',
},
COMPLETED: {
label: msg`Completed`,
labelExtended: msg`Document completed`,
icon: CheckCircle2,
color: 'text-green-500 dark:text-green-300',
},
DRAFT: {
label: msg`Draft`,
labelExtended: msg`Document draft`,
icon: File,
color: 'text-yellow-500 dark:text-yellow-200',
},
INBOX: {
label: msg`Inbox`,
labelExtended: msg`Document inbox`,
icon: SignatureIcon,
color: 'text-muted-foreground',
},
ALL: {
label: msg`All`,
labelExtended: msg`Document All`,
color: 'text-muted-foreground',
},
};
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
status: ExtendedDocumentStatus;
inheritColor?: boolean;
};
export const DocumentStatus = ({
className,
status,
inheritColor,
...props
}: DocumentStatusProps) => {
const { _ } = useLingui();
const { label, icon: Icon, color } = FRIENDLY_STATUS_MAP[status];
return (
<span className={cn('flex items-center', className)} {...props}>
{Icon && (
<Icon
className={cn('mr-2 inline-block h-4 w-4', {
[color]: !inheritColor,
})}
/>
)}
{_(label)}
</span>
);
};

View File

@ -0,0 +1,131 @@
'use client';
import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { CheckCircle, EyeIcon, Pencil } from 'lucide-react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { type DocumentData, type Prisma, RecipientRole } from '@documenso/prisma/client';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { Button } from '@documenso/ui/primitives/button';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@documenso/ui/primitives/sheet';
import { DocumentStatus } from './document-status';
type GetNextInboxDocumentResult =
| Prisma.DocumentGetPayload<{
select: {
id: true;
createdAt: true;
title: true;
status: true;
recipients: {
select: {
token: true;
role: true;
};
};
documentMeta: true;
};
}>[]
| null;
export type NextInboxItemButtonProps = HTMLAttributes<HTMLButtonElement> & {
disabled?: boolean;
documentData?: DocumentData;
userEmail: string | undefined;
nextInboxDocument: GetNextInboxDocumentResult;
};
export const NextInboxItemButton = ({
className,
documentData,
nextInboxDocument,
userEmail,
disabled,
...props
}: NextInboxItemButtonProps) => {
return (
<Sheet>
<SheetTrigger asChild>
<Button
type="button"
variant="outline"
className={className}
disabled={disabled || !documentData || !userEmail}
{...props}
>
<SignatureIcon className="mr-2 h-5 w-5" />
<Trans>Sign Next Document</Trans>
</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle className="text-2xl">Inbox</SheetTitle>
<SheetDescription>Documents awaiting your signature or review</SheetDescription>
</SheetHeader>
<div className="mt-8 space-y-6">
{nextInboxDocument?.map((document) => {
const recipient = document.recipients[0];
return (
<div key={document.id} className="flex items-center justify-between space-y-1">
<div>
<p className="text-foreground text-lg font-semibold">{document.title}</p>
<div className="flex items-center gap-x-2">
<DocumentStatus status={document.status} />
{document.createdAt && (
<p className="text-muted-foreground">
<Trans>
Created {DateTime.fromJSDate(document.createdAt).toFormat('LLL yy')}
</Trans>
</p>
)}
</div>
</div>
<Button asChild className="w-28">
<Link href={`/sign/${recipient?.token}`}>
{match(recipient?.role)
.with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
<Trans>Sign</Trans>
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
<Trans>Approve</Trans>
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>View</Trans>
</>
))}
</Link>
</Button>
</div>
);
})}
</div>
</SheetContent>
</Sheet>
);
};

View File

@ -78,6 +78,7 @@
"tailwind-merge": "^1.12.0", "tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5", "tailwindcss-animate": "^1.0.5",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"vaul": "^1.0.0",
"zod": "3.24.1" "zod": "3.24.1"
} }
} }

View File

@ -49,7 +49,6 @@ export type AddSignersFormProps = {
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
signingOrder?: DocumentSigningOrder | null; signingOrder?: DocumentSigningOrder | null;
modifyNextSigner?: boolean | null;
isDocumentEnterprise: boolean; isDocumentEnterprise: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void; onSubmit: (_data: TAddSignersFormSchema) => void;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
@ -60,7 +59,6 @@ export const AddSignersFormPartial = ({
recipients, recipients,
fields, fields,
signingOrder, signingOrder,
modifyNextSigner,
isDocumentEnterprise, isDocumentEnterprise,
onSubmit, onSubmit,
isDocumentPdfLoaded, isDocumentPdfLoaded,
@ -109,7 +107,6 @@ export const AddSignersFormPartial = ({
) )
: defaultRecipients, : defaultRecipients,
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL, signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
modifyNextSigner: modifyNextSigner ?? false,
}, },
}); });
@ -407,35 +404,6 @@ export const AddSignersFormPartial = ({
</FormItem> </FormItem>
)} )}
/> />
{isSigningOrderSequential && (
<FormField
control={form.control}
name="modifyNextSigner"
render={({ field }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
id="modifyNextSigner"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
disabled={isSubmitting || hasDocumentBeenSent}
/>
</FormControl>
<FormLabel
htmlFor="modifyNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Modify next signer</Trans>
</FormLabel>
</FormItem>
)}
/>
)}
<DragDropContext <DragDropContext
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
sensors={[ sensors={[

View File

@ -25,7 +25,6 @@ export const ZAddSignersFormSchema = z
}), }),
), ),
signingOrder: z.nativeEnum(DocumentSigningOrder), signingOrder: z.nativeEnum(DocumentSigningOrder),
modifyNextSigner: z.boolean(),
}) })
.refine( .refine(
(schema) => { (schema) => {

View File

@ -12,6 +12,7 @@ import { match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta'; import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta'; import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
import { useSignerColors } from '../../lib/signer-colors'; import { useSignerColors } from '../../lib/signer-colors';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
@ -185,11 +186,35 @@ export const FieldItem = ({
() => hasFieldMetaValues('CHECKBOX', field.fieldMeta, ZCheckboxFieldMeta), () => hasFieldMetaValues('CHECKBOX', field.fieldMeta, ZCheckboxFieldMeta),
[field.fieldMeta], [field.fieldMeta],
); );
const radioHasValues = useMemo( const radioHasValues = useMemo(
() => hasFieldMetaValues('RADIO', field.fieldMeta, ZRadioFieldMeta), () => hasFieldMetaValues('RADIO', field.fieldMeta, ZRadioFieldMeta),
[field.fieldMeta], [field.fieldMeta],
); );
const hasCheckedValues = (fieldMeta: TFieldMetaSchema, type: FieldType) => {
if (!fieldMeta || (type !== FieldType.RADIO && type !== FieldType.CHECKBOX)) {
return false;
}
if (type === FieldType.RADIO) {
const parsed = ZRadioFieldMeta.parse(fieldMeta);
return parsed.values?.some((value) => value.checked) ?? false;
}
if (type === FieldType.CHECKBOX) {
const parsed = ZCheckboxFieldMeta.parse(fieldMeta);
return parsed.values?.some((value) => value.checked) ?? false;
}
return false;
};
const fieldHasCheckedValues = useMemo(
() => hasCheckedValues(field.fieldMeta, field.type),
[field.fieldMeta, field.type],
);
const fixedSize = checkBoxHasValues || radioHasValues; const fixedSize = checkBoxHasValues || radioHasValues;
return createPortal( return createPortal(
@ -229,6 +254,21 @@ export const FieldItem = ({
onMove?.(d.node); onMove?.(d.node);
}} }}
> >
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
{
'bg-foreground/5 border-primary border': !fieldHasCheckedValues,
'bg-documenso-200 border-primary border': fieldHasCheckedValues,
},
)}
>
{field.fieldMeta.label}
</div>
)}
<div <div
className={cn( className={cn(
'relative flex h-full w-full items-center justify-center bg-white', 'relative flex h-full w-full items-center justify-center bg-white',

View File

@ -126,6 +126,18 @@ export const CheckboxFieldAdvancedSettings = ({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="mb-2">
<Label>
<Trans>Label</Trans>
</Label>
<Input
id="label"
className="bg-background mt-2"
placeholder={_(msg`Field label`)}
value={fieldState.label}
onChange={(e) => handleFieldChange('label', e.target.value)}
/>
</div>
<div className="flex flex-row items-center gap-x-4"> <div className="flex flex-row items-center gap-x-4">
<div className="flex w-2/3 flex-col"> <div className="flex w-2/3 flex-col">
<Label> <Label>

View File

@ -2,7 +2,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Trans } from '@lingui/macro'; import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { ChevronDown, ChevronUp, Trash } from 'lucide-react'; import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio'; import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
@ -27,6 +28,8 @@ export const RadioFieldAdvancedSettings = ({
handleFieldChange, handleFieldChange,
handleErrors, handleErrors,
}: RadioFieldAdvancedSettingsProps) => { }: RadioFieldAdvancedSettingsProps) => {
const { _ } = useLingui();
const [showValidation, setShowValidation] = useState(false); const [showValidation, setShowValidation] = useState(false);
const [values, setValues] = useState( const [values, setValues] = useState(
fieldState.values ?? [{ id: 1, checked: false, value: 'Default value' }], fieldState.values ?? [{ id: 1, checked: false, value: 'Default value' }],
@ -102,6 +105,18 @@ export const RadioFieldAdvancedSettings = ({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div>
<Label>
<Trans>Label</Trans>
</Label>
<Input
id="label"
className="bg-background mt-2"
placeholder={_(msg`Field label`)}
value={fieldState.label}
onChange={(e) => handleFieldChange('label', e.target.value)}
/>
</div>
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<Switch <Switch
className="bg-background" className="bg-background"

View File

@ -183,12 +183,12 @@ const FormMessage = React.forwardRef<
FormMessage.displayName = 'FormMessage'; FormMessage.displayName = 'FormMessage';
export { export {
useFormField,
Form, Form,
FormControl,
FormDescription,
FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormControl,
FormDescription,
FormMessage, FormMessage,
useFormField, FormField,
}; };