mirror of
https://github.com/documenso/documenso.git
synced 2026-06-23 04:42:09 +10:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f70082146 | |||
| 31ba6d5f00 | |||
| c4f89a87a2 | |||
| 89d6dd5b0e | |||
| 08a9ab3aaf | |||
| e66bd422e3 | |||
| 0f5814ff89 | |||
| 1275a15571 | |||
| 22d99c7410 | |||
| 26a36487d4 | |||
| 2ee6b90c99 | |||
| f70e6ac50a | |||
| 7a94ee3b83 | |||
| e39924714a | |||
| c9604fee64 | |||
| 90f8340af4 | |||
| 28b8d2d415 | |||
| 978a2047d4 | |||
| 0dfa953f54 | |||
| 4774324e07 | |||
| bc19699a58 | |||
| 55480826de | |||
| 327b0eaf86 | |||
| 2de5c1992f | |||
| df0c03816e | |||
| a610a06372 | |||
| d5e085d7ee | |||
| c322356654 | |||
| b16862b480 | |||
| 7065b0dd88 | |||
| dff9cfec05 | |||
| d84cf0e58d | |||
| 5d8b147199 | |||
| 7d28295d42 | |||
| 94646cd48a | |||
| 14db9b8203 | |||
| 6ae672c16b | |||
| e9a9d65937 | |||
| d857dfdb38 | |||
| 11a56f3228 | |||
| 91642ddf0b | |||
| e364b08b6a | |||
| 5df3932958 | |||
| ae31860b16 | |||
| 16ee6b7a6d | |||
| 921c3d1ff3 | |||
| 2d7a4d0dde | |||
| d2176627ca | |||
| 17c6098638 | |||
| e5bde53ee4 | |||
| 0663605ffd | |||
| 1bbe561162 | |||
| fbc156722a | |||
| f5d63fb76c | |||
| 374477e692 | |||
| 11d9bde8f8 |
@@ -23,6 +23,10 @@ NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
||||
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
|
||||
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
|
||||
NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
|
||||
# Specifies the prompt to use for OIDC signin, explicitly setting
|
||||
# an empty string will omit the prompt parameter.
|
||||
# See: https://www.cerberauth.com/blog/openid-connect-oauth2-prompts/
|
||||
NEXT_PRIVATE_OIDC_PROMPT="login"
|
||||
|
||||
# [[URLS]]
|
||||
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||
@@ -134,6 +138,23 @@ NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
|
||||
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
|
||||
|
||||
# [[TELEMETRY]]
|
||||
# OPTIONAL: Set to "true" to disable anonymous telemetry for self-hosted instances.
|
||||
# Telemetry helps us understand how Documenso is being used and improve the product.
|
||||
# We only collect: app version, installation ID, and node ID. No personal data is collected.
|
||||
DOCUMENSO_DISABLE_TELEMETRY=
|
||||
|
||||
# [[AI]]
|
||||
# OPTIONAL: Google Cloud Project ID for Vertex AI.
|
||||
GOOGLE_VERTEX_PROJECT_ID=""
|
||||
# OPTIONAL: Google Cloud region for Vertex AI. Defaults to "global".
|
||||
GOOGLE_VERTEX_LOCATION="global"
|
||||
# OPTIONAL: API key for Google Vertex AI (Gemini). Get your key from:
|
||||
# https://console.cloud.google.com/vertex-ai/studio/settings/api-keys
|
||||
GOOGLE_VERTEX_API_KEY=""
|
||||
|
||||
# [[E2E Tests]]
|
||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||
|
||||
@@ -36,6 +36,8 @@ jobs:
|
||||
- name: Build the docker image
|
||||
env:
|
||||
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
|
||||
NEXT_PRIVATE_TELEMETRY_KEY: ${{ secrets.NEXT_PRIVATE_TELEMETRY_KEY }}
|
||||
NEXT_PRIVATE_TELEMETRY_HOST: ${{ secrets.NEXT_PRIVATE_TELEMETRY_HOST }}
|
||||
run: |
|
||||
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||
GIT_SHA="$(git rev-parse HEAD)"
|
||||
@@ -43,6 +45,8 @@ jobs:
|
||||
docker build \
|
||||
-f ./docker/Dockerfile \
|
||||
--progress=plain \
|
||||
--build-arg NEXT_PRIVATE_TELEMETRY_KEY="${NEXT_PRIVATE_TELEMETRY_KEY:-}" \
|
||||
--build-arg NEXT_PRIVATE_TELEMETRY_HOST="${NEXT_PRIVATE_TELEMETRY_HOST:-}" \
|
||||
-t "documenso/documenso-$BUILD_PLATFORM:latest" \
|
||||
-t "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA" \
|
||||
-t "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION" \
|
||||
|
||||
@@ -60,3 +60,6 @@ CLAUDE.md
|
||||
|
||||
# agents
|
||||
.specs
|
||||
|
||||
# scripts
|
||||
scripts/output*
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
auto-install-peers = true
|
||||
legacy-peer-deps = true
|
||||
prefer-dedupe = true
|
||||
@@ -1,6 +1,3 @@
|
||||
> 🚨 🚨 🚨
|
||||
> Documenso 2.0.0 is live on Product Hunt 🎉 <a href="https://documen.so/launch" target="_blank" rel="noopener noreferrer" style="text-decoration: underline;">Join us to celebrate the best Documenso yet 🪩</a>
|
||||
|
||||
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
||||
|
||||
<p align="center" style="margin-top: 20px">
|
||||
@@ -174,9 +171,11 @@ git clone https://github.com/<your-username>/documenso
|
||||
|
||||
5. Create the database schema by running `npm run prisma:migrate-dev`
|
||||
|
||||
6. Run `npm run dev` in the root directory to start
|
||||
6. Run `npm run translate:compile` in the root directory to compile lingui
|
||||
|
||||
7. Register a new user at http://localhost:3000/signup
|
||||
7. Run `npm run dev` in the root directory to start
|
||||
|
||||
8. Register a new user at http://localhost:3000/signup
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
import nextra from 'nextra';
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: [
|
||||
'@documenso/assets',
|
||||
'@documenso/lib',
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3002",
|
||||
"build": "next build && next-sitemap",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3002",
|
||||
"lint:fix": "next lint --fix",
|
||||
"clean": "rimraf .next && rimraf node_modules"
|
||||
@@ -15,18 +15,18 @@
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"next": "14.2.28",
|
||||
"next-plausible": "^3.12.0",
|
||||
"nextra": "^2.13.4",
|
||||
"nextra-theme-docs": "^2.13.4",
|
||||
"next": "^15.5.7",
|
||||
"next-plausible": "^3.12.5",
|
||||
"nextra": "^3",
|
||||
"nextra-theme-docs": "^3",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react-dom": "^18",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"pagefind": "^1.2.0",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { PlausibleProvider } from '../providers/plausible.tsx';
|
||||
import '../styles.css';
|
||||
|
||||
export default function App({ Component, pageProps }) {
|
||||
return (
|
||||
<PlausibleProvider>
|
||||
<Component {...pageProps} />
|
||||
</PlausibleProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from 'react';
|
||||
|
||||
import { PlausibleProvider } from '../providers/plausible';
|
||||
import '../styles.css';
|
||||
|
||||
export type AppProps = {
|
||||
Component: React.ComponentType<any>;
|
||||
pageProps: any;
|
||||
};
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<PlausibleProvider>
|
||||
<Component {...pageProps} />
|
||||
</PlausibleProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export default {
|
||||
index: {
|
||||
type: 'page',
|
||||
title: 'Home',
|
||||
display: 'hidden',
|
||||
theme: {
|
||||
timestamp: false,
|
||||
},
|
||||
},
|
||||
users: {
|
||||
type: 'page',
|
||||
title: 'Users',
|
||||
},
|
||||
developers: {
|
||||
type: 'page',
|
||||
title: 'Developers',
|
||||
},
|
||||
updates: {
|
||||
title: "What's New",
|
||||
type: 'menu',
|
||||
items: {
|
||||
changelog: {
|
||||
title: 'Changelog',
|
||||
href: 'https://documenso.com/changelog',
|
||||
newWindow: true,
|
||||
},
|
||||
blog: {
|
||||
title: 'Blog',
|
||||
href: 'https://documenso.com/blog',
|
||||
newWindow: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"index": {
|
||||
"type": "page",
|
||||
"title": "Home",
|
||||
"display": "hidden",
|
||||
"theme": {
|
||||
"timestamp": false
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"type": "page",
|
||||
"title": "Users"
|
||||
},
|
||||
"developers": {
|
||||
"type": "page",
|
||||
"title": "Developers"
|
||||
},
|
||||
"updates": {
|
||||
"title": "What's New",
|
||||
"type": "menu",
|
||||
"items": {
|
||||
"changelog": {
|
||||
"title": "Changelog",
|
||||
"href": "https://documenso.com/changelog",
|
||||
"newWindow": true
|
||||
},
|
||||
"blog": {
|
||||
"title": "Blog",
|
||||
"href": "https://documenso.com/blog",
|
||||
"newWindow": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export default {
|
||||
index: 'Introduction',
|
||||
'-- Development & Deployment': {
|
||||
type: 'separator',
|
||||
title: 'Development & Deployment',
|
||||
},
|
||||
'local-development': 'Local Development',
|
||||
'developer-mode': 'Developer Mode',
|
||||
'self-hosting': 'Self Hosting',
|
||||
contributing: 'Contributing',
|
||||
'-- API & Integration Guides': {
|
||||
type: 'separator',
|
||||
title: 'API & Integration Guides',
|
||||
},
|
||||
'public-api': 'Public API',
|
||||
embedding: 'Embedding',
|
||||
webhooks: 'Webhooks',
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"index": "Introduction",
|
||||
"-- Development & Deployment": {
|
||||
"type": "separator",
|
||||
"title": "Development & Deployment"
|
||||
},
|
||||
"local-development": "Local Development",
|
||||
"developer-mode": "Developer Mode",
|
||||
"self-hosting": "Self Hosting",
|
||||
"contributing": "Contributing",
|
||||
"-- API & Integration Guides": {
|
||||
"type": "separator",
|
||||
"title": "API & Integration Guides"
|
||||
},
|
||||
"public-api": "Public API",
|
||||
"embedding": "Embedding",
|
||||
"webhooks": "Webhooks"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
index: 'Getting Started',
|
||||
'contributing-translations': 'Contributing Translations',
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"index": "Getting Started",
|
||||
"contributing-translations": "Contributing Translations"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export default {
|
||||
index: 'Get Started',
|
||||
react: 'React Integration',
|
||||
vue: 'Vue Integration',
|
||||
svelte: 'Svelte Integration',
|
||||
solid: 'Solid Integration',
|
||||
preact: 'Preact Integration',
|
||||
angular: 'Angular Integration',
|
||||
'css-variables': 'CSS Variables',
|
||||
authoring: 'Authoring',
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"index": "Get Started",
|
||||
"react": "React Integration",
|
||||
"vue": "Vue Integration",
|
||||
"svelte": "Svelte Integration",
|
||||
"solid": "Solid Integration",
|
||||
"preact": "Preact Integration",
|
||||
"angular": "Angular Integration",
|
||||
"css-variables": "CSS Variables",
|
||||
"authoring": "Authoring"
|
||||
}
|
||||
@@ -5,18 +5,38 @@ description: Learn how to use embedded authoring to create documents and templat
|
||||
|
||||
# Embedded Authoring
|
||||
|
||||
In addition to embedding signing experiences, Documenso now supports embedded authoring, allowing you to integrate document and template creation directly within your application.
|
||||
In addition to embedding signing experiences, Documenso now supports embedded authoring, allowing you to integrate document and template creation and editing directly within your application.
|
||||
|
||||
## How Embedded Authoring Works
|
||||
|
||||
The embedded authoring feature enables your users to create new documents without leaving your application. This process works through secure presign tokens that authenticate the embedding session and manage permissions.
|
||||
The embedded authoring feature enables your users to create and edit documents and templates without leaving your application. This process works through secure presign tokens that authenticate the embedding session and manage permissions.
|
||||
|
||||
## Creating Documents with Embedded Authoring
|
||||
## Available Components
|
||||
|
||||
To implement document creation in your application, use the `EmbedCreateDocument` component from our SDK:
|
||||
The SDK provides four authoring components:
|
||||
|
||||
- **`EmbedCreateDocumentV1`** - Create new documents
|
||||
- **`EmbedCreateTemplateV1`** - Create new templates
|
||||
- **`EmbedUpdateDocumentV1`** - Edit existing documents
|
||||
- **`EmbedUpdateTemplateV1`** - Edit existing templates
|
||||
|
||||
React Example:
|
||||
|
||||
```jsx
|
||||
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
|
||||
import {
|
||||
EmbedCreateDocumentV1,
|
||||
EmbedCreateTemplateV1,
|
||||
EmbedUpdateDocumentV1,
|
||||
EmbedUpdateTemplateV1,
|
||||
} from '@documenso/embed-react';
|
||||
```
|
||||
|
||||
## Creating Documents
|
||||
|
||||
To implement document creation in your application, use the `EmbedCreateDocumentV1` component:
|
||||
|
||||
```jsx
|
||||
import { EmbedCreateDocumentV1 } from '@documenso/embed-react';
|
||||
|
||||
const DocumentCreator = () => {
|
||||
// You'll need to obtain a presign token using your API key
|
||||
@@ -37,9 +57,88 @@ const DocumentCreator = () => {
|
||||
};
|
||||
```
|
||||
|
||||
## Creating Templates
|
||||
|
||||
To create templates, use the `EmbedCreateTemplateV1` component:
|
||||
|
||||
```jsx
|
||||
import { EmbedCreateTemplateV1 } from '@documenso/embed-react';
|
||||
|
||||
const TemplateCreator = () => {
|
||||
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<EmbedCreateTemplate
|
||||
presignToken={presignToken}
|
||||
externalId="template-12345"
|
||||
onTemplateCreated={(data) => {
|
||||
console.log('Template created with ID:', data.templateId);
|
||||
console.log('External reference ID:', data.externalId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Updating Documents
|
||||
|
||||
To edit existing documents, use the `EmbedUpdateDocumentV1` component:
|
||||
|
||||
```jsx
|
||||
import { EmbedUpdateDocumentV1 } from '@documenso/embed-react';
|
||||
|
||||
const DocumentEditor = () => {
|
||||
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||
const documentId = 123; // The ID of the document to edit
|
||||
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<EmbedUpdateDocument
|
||||
presignToken={presignToken}
|
||||
documentId={documentId}
|
||||
externalId="order-12345"
|
||||
onlyEditFields={false}
|
||||
onDocumentUpdated={(data) => {
|
||||
console.log('Document updated:', data.documentId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Updating Templates
|
||||
|
||||
To edit existing templates, use the `EmbedUpdateTemplateV1` component:
|
||||
|
||||
```jsx
|
||||
import { EmbedUpdateTemplateV1 } from '@documenso/embed-react';
|
||||
|
||||
const TemplateEditor = () => {
|
||||
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||
const templateId = 456; // The ID of the template to edit
|
||||
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<EmbedUpdateTemplate
|
||||
presignToken={presignToken}
|
||||
templateId={templateId}
|
||||
externalId="template-12345"
|
||||
onlyEditFields={false}
|
||||
onTemplateUpdated={(data) => {
|
||||
console.log('Template updated:', data.templateId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Obtaining a Presign Token
|
||||
|
||||
Before using the `EmbedCreateDocument` component, you'll need to obtain a presign token from your backend. This token authorizes the embedding session.
|
||||
Before using any of the authoring components, you'll need to obtain a presign token from your backend. This token authorizes the embedding session.
|
||||
|
||||
You can create a presign token by making a request to:
|
||||
|
||||
@@ -53,17 +152,29 @@ You can find more details on this request at our [API Documentation](https://ope
|
||||
|
||||
## Configuration Options
|
||||
|
||||
The `EmbedCreateDocument` component accepts several configuration options:
|
||||
All authoring components accept the following configuration options:
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------------------ | ------- | ------------------------------------------------------------------ |
|
||||
| `presignToken` | string | **Required**. The authentication token for the embedding session. |
|
||||
| `externalId` | string | Optional reference ID from your system to link with the document. |
|
||||
| `host` | string | Optional custom host URL. Defaults to `https://app.documenso.com`. |
|
||||
| `css` | string | Optional custom CSS to style the embedded component. |
|
||||
| `cssVars` | object | Optional CSS variables for colors, spacing, and more. |
|
||||
| `darkModeDisabled` | boolean | Optional flag to disable dark mode. |
|
||||
| `className` | string | Optional CSS class name for the iframe. |
|
||||
| Option | Type | Description |
|
||||
| ------------------ | ------- | -------------------------------------------------------------------------- |
|
||||
| `presignToken` | string | **Required**. The authentication token for the embedding session. |
|
||||
| `externalId` | string | Optional reference ID from your system to link with the document/template. |
|
||||
| `host` | string | Optional custom host URL. Defaults to `https://app.documenso.com`. |
|
||||
| `css` | string | Optional custom CSS to style the embedded component. |
|
||||
| `cssVars` | object | Optional CSS variables for colors, spacing, and more. |
|
||||
| `darkModeDisabled` | boolean | Optional flag to disable dark mode. |
|
||||
| `className` | string | Optional CSS class name for the iframe. |
|
||||
| `additionalProps` | object | Optional additional props to pass to the iframe (for testing features). |
|
||||
| `features` | object | Optional feature toggles to customize the authoring experience. |
|
||||
|
||||
### Update Component Specific Props
|
||||
|
||||
The `EmbedUpdateDocument` and `EmbedUpdateTemplate` components also accept:
|
||||
|
||||
| Option | Type | Description |
|
||||
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `documentId` | number | **Required for EmbedUpdateDocument**. The ID of the document to edit. |
|
||||
| `templateId` | number | **Required for EmbedUpdateTemplate**. The ID of the template to edit. |
|
||||
| `onlyEditFields` | boolean | Optional flag to restrict editing to fields only skipping the recipient configuration step (default: `false`). |
|
||||
|
||||
## Feature Toggles
|
||||
|
||||
@@ -83,9 +194,11 @@ You can customize the authoring experience by enabling or disabling specific fea
|
||||
/>
|
||||
```
|
||||
|
||||
## Handling Document Creation Events
|
||||
## Handling Events
|
||||
|
||||
The `onDocumentCreated` callback is triggered when a document is successfully created, providing both the document ID and your external reference ID:
|
||||
Each component provides callbacks for handling completion events:
|
||||
|
||||
### Document Events
|
||||
|
||||
```jsx
|
||||
<EmbedCreateDocument
|
||||
@@ -99,11 +212,47 @@ The `onDocumentCreated` callback is triggered when a document is successfully cr
|
||||
updateOrderDocument(data.externalId, data.documentId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EmbedUpdateDocument
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
documentId={123}
|
||||
onDocumentUpdated={(data) => {
|
||||
console.log('Document updated:', data.documentId);
|
||||
// Handle document update
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Template Events
|
||||
|
||||
```jsx
|
||||
<EmbedCreateTemplate
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
externalId="template-12345"
|
||||
onTemplateCreated={(data) => {
|
||||
console.log('Template created:', data.templateId);
|
||||
// Handle template creation
|
||||
}}
|
||||
/>
|
||||
|
||||
<EmbedUpdateTemplate
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
templateId={456}
|
||||
onTemplateUpdated={(data) => {
|
||||
console.log('Template updated:', data.templateId);
|
||||
// Handle template update
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
All event callbacks receive an object with:
|
||||
|
||||
- `documentId` or `templateId` - The ID of the created/updated document or template
|
||||
- `externalId` - Your external reference ID (if provided)
|
||||
|
||||
## Styling the Embedded Component
|
||||
|
||||
You can customize the appearance of the embedded component using standard CSS classes:
|
||||
You can customize the appearance of the embedded component using standard CSS classes, custom CSS, and CSS variables:
|
||||
|
||||
```jsx
|
||||
<EmbedCreateDocument
|
||||
@@ -130,20 +279,48 @@ Here's a complete example of integrating document creation in a React applicatio
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
|
||||
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
|
||||
import { EmbedCreateDocumentV1, EmbedUpdateDocumentV1 } from '@documenso/embed-react';
|
||||
|
||||
function DocumentCreator() {
|
||||
function DocumentManager() {
|
||||
// In a real application, you would fetch this token from your backend
|
||||
// using your API key at /api/v2/embedding/create-presign-token
|
||||
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||
const [documentId, setDocumentId] = useState<number | null>(null);
|
||||
const [mode, setMode] = useState<'create' | 'edit'>('create');
|
||||
|
||||
if (documentId) {
|
||||
if (documentId && mode === 'create') {
|
||||
return (
|
||||
<div>
|
||||
<h2>Document Created Successfully!</h2>
|
||||
<p>Document ID: {documentId}</p>
|
||||
<button onClick={() => setDocumentId(null)}>Create Another Document</button>
|
||||
<div>
|
||||
<button onClick={() => setMode('edit')}>Edit Document</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDocumentId(null);
|
||||
setMode('create');
|
||||
}}
|
||||
>
|
||||
Create Another Document
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'edit' && documentId) {
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<button onClick={() => setMode('create')}>Back to Create</button>
|
||||
<EmbedUpdateDocument
|
||||
presignToken={presignToken}
|
||||
documentId={documentId}
|
||||
externalId="order-12345"
|
||||
onDocumentUpdated={(data) => {
|
||||
console.log('Document updated:', data.documentId);
|
||||
setMode('create');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -153,6 +330,14 @@ function DocumentCreator() {
|
||||
<EmbedCreateDocument
|
||||
presignToken={presignToken}
|
||||
externalId="order-12345"
|
||||
features={{
|
||||
allowConfigureSignatureTypes: true,
|
||||
allowConfigureLanguage: true,
|
||||
allowConfigureDateFormat: true,
|
||||
allowConfigureTimezone: true,
|
||||
allowConfigureRedirectUrl: true,
|
||||
allowConfigureCommunication: true,
|
||||
}}
|
||||
onDocumentCreated={(data) => {
|
||||
setDocumentId(data.documentId);
|
||||
}}
|
||||
@@ -161,7 +346,38 @@ function DocumentCreator() {
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentCreator;
|
||||
export default DocumentManager;
|
||||
```
|
||||
|
||||
With embedded authoring, your users can seamlessly create documents within your application, enhancing the overall user experience and streamlining document workflows.
|
||||
## Advanced Usage
|
||||
|
||||
### Using Additional Props
|
||||
|
||||
You can pass additional props to the iframe for testing features before they're officially supported:
|
||||
|
||||
```jsx
|
||||
<EmbedCreateDocument
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
additionalProps={{
|
||||
experimentalFeature: true,
|
||||
customSetting: 'value',
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Restricting To Only Field Editing
|
||||
|
||||
When updating documents or templates, you can restrict editing to fields only skipping the recipient configuration step:
|
||||
|
||||
```jsx
|
||||
<EmbedUpdateDocument
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
documentId={123}
|
||||
onlyEditFields={true}
|
||||
onDocumentUpdated={(data) => {
|
||||
console.log('Fields updated:', data.documentId);
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
With embedded authoring, your users can seamlessly create and edit documents and templates within your application, enhancing the overall user experience and streamlining document workflows.
|
||||
|
||||
@@ -3,16 +3,16 @@ title: Developer Documentation
|
||||
description: Learn how to run Documenso locally, use our API, integrate webhooks, contribute to the project, and self-host Documenso.
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'nextra/components';
|
||||
import { Cards } from 'nextra/components';
|
||||
|
||||
# Developer Documentation
|
||||
|
||||
The developer documentation is a comprehensive guide to help you:
|
||||
|
||||
<Cards>
|
||||
<Card title="Set up dev environment" href="/developers/local-development" />
|
||||
<Card title="Use the API" href="/developers/public-api" />
|
||||
<Card title="Integrate webhooks" href="/developers/webhooks" />
|
||||
<Card title="Contribute to the project" href="/developers/contributing" />
|
||||
<Card title="Self-host Documenso" href="/developers/self-hosting" />
|
||||
<Cards.Card title="Set up dev environment" href="/developers/local-development" />
|
||||
<Cards.Card title="Use the API" href="/developers/public-api" />
|
||||
<Cards.Card title="Integrate webhooks" href="/developers/webhooks" />
|
||||
<Cards.Card title="Contribute to the project" href="/developers/contributing" />
|
||||
<Cards.Card title="Self-host Documenso" href="/developers/self-hosting" />
|
||||
</Cards>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
index: 'Get Started',
|
||||
quickstart: 'Developer Quickstart',
|
||||
manual: 'Manual Setup',
|
||||
gitpod: 'Gitpod',
|
||||
'signing-certificate': 'Signing Certificate',
|
||||
translations: 'Translations',
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"index": "Get Started",
|
||||
"quickstart": "Developer Quickstart",
|
||||
"manual": "Manual Setup",
|
||||
"gitpod": "Gitpod",
|
||||
"signing-certificate": "Signing Certificate",
|
||||
"translations": "Translations"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
index: 'Get Started',
|
||||
authentication: 'Authentication',
|
||||
'rate-limits': 'Rate Limits',
|
||||
versioning: 'Versioning',
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"index": "Get Started",
|
||||
"authentication": "Authentication",
|
||||
"rate-limits": "Rate Limits",
|
||||
"versioning": "Versioning"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
index: 'Getting Started',
|
||||
'signing-certificate': 'Signing Certificate',
|
||||
'how-to': 'How To',
|
||||
'setting-up-oauth-providers': 'Setting up OAuth Providers',
|
||||
telemetry: 'Telemetry',
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"index": "Getting Started",
|
||||
"signing-certificate": "Signing Certificate",
|
||||
"how-to": "How To",
|
||||
"setting-up-oauth-providers": "Setting up OAuth Providers"
|
||||
}
|
||||
@@ -275,7 +275,15 @@ The environment variables listed above are a subset of those available for confi
|
||||
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
|
||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
|
||||
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
|
||||
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
|
||||
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
|
||||
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
|
||||
@@ -297,6 +305,7 @@ The environment variables listed above are a subset of those available for confi
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
|
||||
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
|
||||
@@ -308,6 +317,37 @@ The environment variables listed above are a subset of those available for confi
|
||||
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
|
||||
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
|
||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry (see [Telemetry](#telemetry) section below). |
|
||||
|
||||
## Telemetry
|
||||
|
||||
Documenso collects anonymous telemetry data to help us understand how the software is being used and improve the product. This telemetry is **enabled by default** for self-hosted instances.
|
||||
|
||||
### What We Collect
|
||||
|
||||
We collect minimal, privacy-preserving data:
|
||||
|
||||
- **App Version**: The version of Documenso you are running
|
||||
- **Installation ID**: A unique identifier for your installation (stored in your database)
|
||||
- **Node ID**: A unique identifier for each server/container instance (stored in the OS temp directory)
|
||||
|
||||
We do **not** collect any personal data, document contents, user information, or usage patterns.
|
||||
|
||||
### Events
|
||||
|
||||
- **Server Startup**: Captured once when the server starts
|
||||
- **Server Heartbeat**: Captured every hour while the server is running
|
||||
|
||||
### Disabling Telemetry
|
||||
|
||||
To disable telemetry, set the following environment variable:
|
||||
|
||||
```bash
|
||||
DOCUMENSO_DISABLE_TELEMETRY=true
|
||||
```
|
||||
|
||||
This will completely disable all telemetry data collection.
|
||||
|
||||
## Run as a Service
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Telemetry
|
||||
|
||||
Documenso collects anonymous telemetry data from self-hosted instances to help us understand how the software is being used and make improvements to the product. This telemetry is enabled by default, but you can easily disable it if you prefer.
|
||||
|
||||
## What We Collect
|
||||
|
||||
We collect minimal, privacy-preserving information that helps us understand the health and adoption of self-hosted installations:
|
||||
|
||||
- **App Version**: The version of Documenso you are running. This helps us understand which versions are in use and prioritize support for older versions.
|
||||
|
||||
- **Installation ID**: A unique identifier for your installation. This is stored in your database and helps us count distinct installations without knowing who you are.
|
||||
|
||||
- **Node ID**: A unique identifier for each server or container instance. This is stored in your operating system's temporary directory and helps us understand deployment patterns (for example, how many instances are running in a cluster).
|
||||
|
||||
### What We Don't Collect
|
||||
|
||||
We do **not** collect any of the following:
|
||||
|
||||
- Personal information about you or your users
|
||||
- Document contents or file names
|
||||
- User email addresses or names
|
||||
- Usage patterns or feature usage statistics
|
||||
- Server logs or error messages
|
||||
- Any data that could identify your organization or users
|
||||
|
||||
## Why We Collect Telemetry
|
||||
|
||||
The telemetry data we collect serves several important purposes:
|
||||
|
||||
1. **Product Improvement**: Understanding which versions are in use helps us prioritize bug fixes and security updates for the versions that matter most.
|
||||
|
||||
2. **Support Planning**: Knowing how many installations exist and their deployment patterns helps us plan support resources and documentation.
|
||||
|
||||
3. **Feature Development**: Understanding deployment patterns (like cluster sizes) helps us make better architectural decisions for future features.
|
||||
|
||||
4. **Community Health**: Tracking adoption helps us understand the growth of the self-hosted community and allocate resources accordingly.
|
||||
|
||||
All of this is done anonymously and in aggregate. We cannot identify you, your organization, or your users from the telemetry data we collect.
|
||||
|
||||
## Events We Track
|
||||
|
||||
We track two simple events:
|
||||
|
||||
- **Server Startup**: Captured once when your server starts. This tells us when installations are first set up or restarted.
|
||||
|
||||
- **Server Heartbeat**: Captured every hour while your server is running. This helps us understand how many active installations exist and their uptime patterns.
|
||||
|
||||
## How to Disable Telemetry
|
||||
|
||||
If you prefer not to send telemetry data, you can disable it by setting an environment variable.
|
||||
|
||||
### Using Environment Variables
|
||||
|
||||
Add the following to your environment configuration:
|
||||
|
||||
```bash
|
||||
DOCUMENSO_DISABLE_TELEMETRY=true
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
If you're using Docker, you can set this in your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- DOCUMENSO_DISABLE_TELEMETRY=true
|
||||
```
|
||||
|
||||
Or pass it when running a container:
|
||||
|
||||
```bash
|
||||
docker run -e DOCUMENSO_DISABLE_TELEMETRY=true ...
|
||||
```
|
||||
|
||||
### After Disabling
|
||||
|
||||
Once you set `DOCUMENSO_DISABLE_TELEMETRY=true` and restart your server, no telemetry data will be sent. The telemetry client will not initialize, and no network requests will be made to our telemetry servers.
|
||||
|
||||
Note: If you previously had telemetry enabled, the installation ID stored in your database will remain, but it will no longer be used or sent anywhere.
|
||||
|
||||
## Questions or Concerns
|
||||
|
||||
If you have questions about our telemetry practices or concerns about privacy, please reach out to us. We're committed to transparency and respect your choice to disable telemetry if you prefer.
|
||||
@@ -0,0 +1,23 @@
|
||||
export default {
|
||||
index: 'Introduction',
|
||||
support: 'Support',
|
||||
'-- How To Use': {
|
||||
type: 'separator',
|
||||
title: 'How To Use',
|
||||
},
|
||||
'get-started': 'Get Started',
|
||||
profile: 'Public Profile',
|
||||
organisations: 'Organisations',
|
||||
documents: 'Documents',
|
||||
templates: 'Templates',
|
||||
branding: 'Branding',
|
||||
'email-domains': 'Email Domains',
|
||||
'direct-links': 'Direct Signing Links',
|
||||
'-- Legal Overview': {
|
||||
type: 'separator',
|
||||
title: 'Legal Overview',
|
||||
},
|
||||
'fair-use': 'Fair Use Policy',
|
||||
licenses: 'Licenses',
|
||||
compliance: 'Compliance',
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"index": "Introduction",
|
||||
"support": "Support",
|
||||
"-- How To Use": {
|
||||
"type": "separator",
|
||||
"title": "How To Use"
|
||||
},
|
||||
"get-started": "Get Started",
|
||||
"profile": "Public Profile",
|
||||
"organisations": "Organisations",
|
||||
"documents": "Documents",
|
||||
"templates": "Templates",
|
||||
"branding": "Branding",
|
||||
"email-domains": "Email Domains",
|
||||
"direct-links": "Direct Signing Links",
|
||||
"-- Legal Overview": {
|
||||
"type": "separator",
|
||||
"title": "Legal Overview"
|
||||
},
|
||||
"fair-use": "Fair Use Policy",
|
||||
"licenses": "Licenses",
|
||||
"compliance": "Compliance"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
'signature-levels': 'Signature Levels',
|
||||
'standards-and-regulations': 'Standards and Regulations',
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"signature-levels": "Signature Levels",
|
||||
"standards-and-regulations": "Standards and Regulations"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
'sending-documents': 'Sending Documents',
|
||||
'document-preferences': 'Document Preferences',
|
||||
'document-visibility': 'Document Visibility',
|
||||
fields: 'Document Fields',
|
||||
'email-preferences': 'Email Preferences',
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"sending-documents": "Sending Documents",
|
||||
"document-preferences": "Document Preferences",
|
||||
"document-visibility": "Document Visibility",
|
||||
"fields": "Document Fields",
|
||||
"email-preferences": "Email Preferences"
|
||||
}
|
||||
@@ -178,7 +178,7 @@ The dropdown/select field collects a single choice from a list of options.
|
||||
|
||||
Place the dropdown/select field on the document where you want the signer to select a choice. The dropdown/select field comes with additional settings that can be configured.
|
||||
|
||||

|
||||
{/*  */}
|
||||
|
||||
The dropdown/select field settings include:
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
index: 'Overview',
|
||||
'community-edition': 'Community Edition',
|
||||
'enterprise-edition': 'Enterprise Edition',
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"index": "Overview",
|
||||
"community-edition": "Community Edition",
|
||||
"enterprise-edition": "Enterprise Edition"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
index: 'Introduction',
|
||||
members: 'Members',
|
||||
groups: 'Groups',
|
||||
teams: 'Teams',
|
||||
sso: 'SSO',
|
||||
billing: 'Billing',
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"index": "Introduction",
|
||||
"members": "Members",
|
||||
"groups": "Groups",
|
||||
"teams": "Teams",
|
||||
"sso": "SSO",
|
||||
"billing": "Billing"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
index: 'Configuration',
|
||||
'microsoft-entra-id': 'Microsoft Entra ID',
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"index": "Configuration",
|
||||
"microsoft-entra-id": "Microsoft Entra ID"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
-22
File diff suppressed because one or more lines are too long
@@ -1,3 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ module.exports = {
|
||||
...baseConfig.content,
|
||||
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./content/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
fontFamily: {
|
||||
@@ -4,7 +4,7 @@ import { useConfig } from 'nextra-theme-docs';
|
||||
const themeConfig: DocsThemeConfig = {
|
||||
logo: <span>Documenso</span>,
|
||||
head: function useHead() {
|
||||
const config = useConfig<{ title?: string; description?: string }>();
|
||||
const config = useConfig();
|
||||
|
||||
const title = `${config.frontMatter.title} | Documenso Docs` || 'Documenso Docs';
|
||||
const description = config.frontMatter.description || 'The official Documenso documentation';
|
||||
@@ -12,6 +12,7 @@ const themeConfig: DocsThemeConfig = {
|
||||
return (
|
||||
<>
|
||||
<meta httpEquiv="Content-Language" content="en" />
|
||||
<title>{title}</title>
|
||||
<meta name="title" content={title} />
|
||||
<meta name="og:title" content={title} />
|
||||
<meta name="description" content={description} />
|
||||
@@ -46,7 +47,7 @@ const themeConfig: DocsThemeConfig = {
|
||||
},
|
||||
docsRepositoryBase: 'https://github.com/documenso/documenso/tree/main/apps/documentation',
|
||||
footer: {
|
||||
text: (
|
||||
content: (
|
||||
<span>
|
||||
{new Date().getFullYear()} ©{' '}
|
||||
<a href="https://documen.so" target="_blank">
|
||||
@@ -56,12 +57,9 @@ const themeConfig: DocsThemeConfig = {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
primaryHue: 100,
|
||||
primarySaturation: 48.47,
|
||||
useNextSeoProps() {
|
||||
return {
|
||||
titleTemplate: '%s | Documenso Docs',
|
||||
};
|
||||
color: {
|
||||
hue: 100,
|
||||
saturation: 48.47,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
@@ -18,10 +22,21 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"forceConsistentCasingInFileNames": true
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "tailwind.config.js"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"tailwind.config.js"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -11,12 +11,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.5.0",
|
||||
"next": "14.2.28"
|
||||
"luxon": "^3.7.2",
|
||||
"next": "^15.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "18.3.5",
|
||||
"@types/react": "18.3.27",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
||||
|
||||
toast({
|
||||
title: _(msg`Document deleted`),
|
||||
description: 'The Document has been deleted successfully.',
|
||||
description: _(msg`The Document has been deleted successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@@ -54,8 +54,9 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to delete your document. Please try again later.',
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to delete your document. Please try again later.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
|
||||
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
|
||||
import {
|
||||
AiApiError,
|
||||
type DetectFieldsProgressEvent,
|
||||
detectFields,
|
||||
} from '../../../server/api/ai/detect-fields.client';
|
||||
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
|
||||
|
||||
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
|
||||
|
||||
type AiFieldDetectionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onComplete: (fields: NormalizedFieldWithContext[]) => void;
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
const PROCESSING_MESSAGES = [
|
||||
msg`Reading your document`,
|
||||
msg`Analyzing page layout`,
|
||||
msg`Looking for form fields`,
|
||||
msg`Detecting signature areas`,
|
||||
msg`Identifying input fields`,
|
||||
msg`Mapping fields to recipients`,
|
||||
msg`Almost done`,
|
||||
] as const;
|
||||
|
||||
const FIELD_TYPE_LABELS: Record<string, MessageDescriptor> = {
|
||||
SIGNATURE: msg`Signature`,
|
||||
INITIALS: msg`Initials`,
|
||||
NAME: msg`Name`,
|
||||
EMAIL: msg`Email`,
|
||||
DATE: msg`Date`,
|
||||
TEXT: msg`Text`,
|
||||
NUMBER: msg`Number`,
|
||||
CHECKBOX: msg`Checkbox`,
|
||||
RADIO: msg`Radio`,
|
||||
};
|
||||
|
||||
export const AiFieldDetectionDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onComplete,
|
||||
envelopeId,
|
||||
teamId,
|
||||
}: AiFieldDetectionDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [state, setState] = useState<DialogState>('PROMPT');
|
||||
const [messageIndex, setMessageIndex] = useState(0);
|
||||
const [detectedFields, setDetectedFields] = useState<NormalizedFieldWithContext[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [context, setContext] = useState('');
|
||||
const [progress, setProgress] = useState<DetectFieldsProgressEvent | null>(null);
|
||||
|
||||
const onDetectClick = useCallback(async () => {
|
||||
setState('PROCESSING');
|
||||
setMessageIndex(0);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
await detectFields({
|
||||
request: {
|
||||
envelopeId,
|
||||
teamId,
|
||||
context: context || undefined,
|
||||
},
|
||||
onProgress: (progressEvent) => {
|
||||
setProgress(progressEvent);
|
||||
},
|
||||
onComplete: (event) => {
|
||||
setDetectedFields(event.fields);
|
||||
setState('REVIEW');
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err instanceof AiApiError && err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : 'Failed to detect fields');
|
||||
setState('ERROR');
|
||||
}
|
||||
}, [envelopeId, teamId, context]);
|
||||
|
||||
const onAddFields = () => {
|
||||
onComplete(detectedFields);
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedFields([]);
|
||||
setContext('');
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedFields([]);
|
||||
setError(null);
|
||||
setContext('');
|
||||
setProgress(null);
|
||||
};
|
||||
|
||||
// Group fields by type for summary display
|
||||
const fieldCountsByType = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const field of detectedFields) {
|
||||
counts[field.type] = (counts[field.type] || 0) + 1;
|
||||
}
|
||||
|
||||
return Object.entries(counts).sort(([, a], [, b]) => b - a);
|
||||
}, [detectedFields]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'PROCESSING') {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-lg" hideClose={true}>
|
||||
{state === 'PROMPT' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detect fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We'll scan your document to find form fields like signature lines, text inputs,
|
||||
checkboxes, and more. Detected fields will be suggested for you to review.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert className="flex items-center gap-2 space-y-0" variant="neutral">
|
||||
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
Your document is processed securely using AI services that don't retain your
|
||||
data.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="context">
|
||||
<Trans>Context</Trans>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="context"
|
||||
placeholder={_(msg`David is the Employee, Lucas is the Manager`)}
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>Help the AI assign fields to the right recipients.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Detect</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'PROCESSING' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detecting fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<AnimatedDocumentScanner />
|
||||
|
||||
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
|
||||
{progress.fieldsDetected} field(s) found
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
|
||||
<Trans>This can take a minute or two depending on the size of your document.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex gap-1">
|
||||
{PROCESSING_MESSAGES.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
|
||||
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'REVIEW' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detected fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{detectedFields.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<FormInputIcon className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<Trans>No fields were detected in your document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground/70">
|
||||
<Trans>You can add fields manually in the editor.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>We found {detectedFields.length} field(s) in your document.</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
{fieldCountsByType.map(([type, count]) => (
|
||||
<li key={type} className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">{_(FIELD_TYPE_LABELS[type]) || type}</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">{count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
{detectedFields.length > 0 && (
|
||||
<Button type="button" onClick={onAddFields}>
|
||||
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Add fields</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'ERROR' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detection failed</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Something went wrong while detecting fields.</Trans>
|
||||
</p>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'RATE_LIMITED' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Too many requests</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You've made too many detection requests. Please wait a minute before trying again.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import {
|
||||
AiApiError,
|
||||
type DetectRecipientsProgressEvent,
|
||||
detectRecipients,
|
||||
} from '../../../server/api/ai/detect-recipients.client';
|
||||
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
|
||||
|
||||
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
|
||||
|
||||
type AiRecipientDetectionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onComplete: (recipients: TDetectedRecipientSchema[]) => void;
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
const PROCESSING_MESSAGES = [
|
||||
msg`Reading your document`,
|
||||
msg`Analyzing pages`,
|
||||
msg`Looking for signature fields`,
|
||||
msg`Identifying recipients`,
|
||||
msg`Extracting contact details`,
|
||||
msg`Almost done`,
|
||||
] as const;
|
||||
|
||||
export const AiRecipientDetectionDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onComplete,
|
||||
envelopeId,
|
||||
teamId,
|
||||
}: AiRecipientDetectionDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [state, setState] = useState<DialogState>('PROMPT');
|
||||
const [messageIndex, setMessageIndex] = useState(0);
|
||||
const [detectedRecipients, setDetectedRecipients] = useState<TDetectedRecipientSchema[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<DetectRecipientsProgressEvent | null>(null);
|
||||
|
||||
const onDetectClick = useCallback(async () => {
|
||||
setState('PROCESSING');
|
||||
setMessageIndex(0);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
await detectRecipients({
|
||||
request: {
|
||||
envelopeId,
|
||||
teamId,
|
||||
},
|
||||
onProgress: (progressEvent) => {
|
||||
setProgress(progressEvent);
|
||||
},
|
||||
onComplete: (event) => {
|
||||
setDetectedRecipients(event.recipients);
|
||||
setState('REVIEW');
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err instanceof AiApiError && err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : 'Failed to detect recipients');
|
||||
setState('ERROR');
|
||||
}
|
||||
}, [envelopeId, teamId]);
|
||||
|
||||
const handleRemoveRecipient = (index: number) => {
|
||||
setDetectedRecipients((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const onAddRecipients = () => {
|
||||
onComplete(detectedRecipients);
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedRecipients([]);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedRecipients([]);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'PROCESSING') {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-lg" hideClose={true}>
|
||||
{state === 'PROMPT' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detect recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We'll scan your document to find signature fields and identify who needs to sign.
|
||||
Detected recipients will be suggested for you to review.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert className="mt-4 flex items-center gap-2 space-y-0" variant="neutral">
|
||||
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
Your document is processed securely using AI services that don't retain your
|
||||
data.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Detect</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'PROCESSING' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detecting recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<AnimatedDocumentScanner />
|
||||
|
||||
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
|
||||
{progress.recipientsDetected} recipient(s) found
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
|
||||
<Trans>This can take a minute or two depending on the size of your document.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex gap-1">
|
||||
{PROCESSING_MESSAGES.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
|
||||
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'REVIEW' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detected recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{detectedRecipients.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<UserIcon className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<Trans>No recipients were detected in your document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground/70">
|
||||
<Trans>You can add recipients manually in the editor.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We found {detectedRecipients.length} recipient(s) in your document.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
{detectedRecipients.map((recipient, index) => (
|
||||
<li key={index} className="flex items-center justify-between px-4 py-3">
|
||||
<AvatarWithText
|
||||
avatarFallback={
|
||||
recipient.name
|
||||
? recipient.name.slice(0, 1).toUpperCase()
|
||||
: recipient.email
|
||||
? recipient.email.slice(0, 1).toUpperCase()
|
||||
: '?'
|
||||
}
|
||||
primaryText={
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{recipient.name || _(msg`Unknown name`)}
|
||||
</p>
|
||||
}
|
||||
secondaryText={
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="italic text-muted-foreground/70">
|
||||
{recipient.email || _(msg`No email detected`)}
|
||||
</p>
|
||||
<p>{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 p-0 text-muted-foreground/80 hover:text-destructive focus-visible:border-destructive focus-visible:ring-destructive"
|
||||
onClick={() => handleRemoveRecipient(index)}
|
||||
>
|
||||
<span className="sr-only">
|
||||
<Trans>Remove recipient</Trans>
|
||||
</span>
|
||||
|
||||
<XIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
{detectedRecipients.length > 0 && (
|
||||
<Button type="button" onClick={onAddRecipients}>
|
||||
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Add recipients</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'ERROR' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detection failed</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Something went wrong while detecting recipients.</Trans>
|
||||
</p>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'RATE_LIMITED' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Too many requests</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You've made too many detection requests. Please wait a minute before trying again.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
@@ -96,7 +96,7 @@ export const DocumentDuplicateDialog = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
key={envelopeItems[0].id}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={undefined}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
DocumentDistributionMethod,
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
type Field,
|
||||
FieldType,
|
||||
type Recipient,
|
||||
RecipientRole,
|
||||
} from '@prisma/client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
@@ -19,8 +17,8 @@ import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -52,16 +50,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopeDistributeDialogProps = {
|
||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||
recipients: Recipient[];
|
||||
fields: Pick<Field, 'type' | 'recipientId'>[];
|
||||
};
|
||||
onDistribute?: () => Promise<void>;
|
||||
documentRootPath: string;
|
||||
trigger?: React.ReactNode;
|
||||
@@ -86,20 +81,20 @@ export const ZEnvelopeDistributeFormSchema = z.object({
|
||||
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
|
||||
|
||||
export const EnvelopeDistributeDialog = ({
|
||||
envelope,
|
||||
trigger,
|
||||
documentRootPath,
|
||||
onDistribute,
|
||||
}: EnvelopeDistributeDialogProps) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const recipients = envelope.recipients;
|
||||
const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const { mutateAsync: distributeEnvelope } = trpcReact.envelope.distribute.useMutation();
|
||||
|
||||
@@ -189,6 +184,29 @@ export const EnvelopeDistributeDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
if (isSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSyncing(true);
|
||||
|
||||
try {
|
||||
await syncEnvelope();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
setIsSyncing(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Resync the whole envelope if the envelope is mid saving.
|
||||
if (isOpen && (isAutosaving || autosaveError)) {
|
||||
void handleSync();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (envelope.status !== DocumentStatus.DRAFT || envelope.type !== EnvelopeType.DOCUMENT) {
|
||||
return null;
|
||||
}
|
||||
@@ -208,7 +226,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!invalidEnvelopeCode ? (
|
||||
{!invalidEnvelopeCode || isSyncing ? (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting}>
|
||||
@@ -236,7 +254,16 @@ export const EnvelopeDistributeDialog = ({
|
||||
})}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{distributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||
{isSyncing ? (
|
||||
<motion.div
|
||||
key={'Flushing'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
>
|
||||
<SpinnerBox spinnerProps={{ size: 'sm' }} className="h-72" />
|
||||
</motion.div>
|
||||
) : distributionMethod === DocumentDistributionMethod.EMAIL ? (
|
||||
<motion.div
|
||||
key={'Emails'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
@@ -339,7 +366,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground p-4">
|
||||
<TooltipContent className="p-4 text-muted-foreground">
|
||||
<DocumentSendEmailMessageHelper />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -347,7 +374,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="bg-background mt-2 h-16 resize-none"
|
||||
className="mt-2 h-16 resize-none bg-background"
|
||||
{...field}
|
||||
maxLength={5000}
|
||||
/>
|
||||
@@ -359,9 +386,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</fieldset>
|
||||
</Form>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{distributionMethod === DocumentDistributionMethod.NONE && (
|
||||
) : distributionMethod === DocumentDistributionMethod.NONE ? (
|
||||
<motion.div
|
||||
key={'Links'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
@@ -369,7 +394,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
className="min-h-60 rounded-lg border"
|
||||
>
|
||||
<div className="text-muted-foreground py-24 text-center text-sm">
|
||||
<div className="py-24 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
<Trans>We won't send anything to notify recipients.</Trans>
|
||||
</p>
|
||||
@@ -382,7 +407,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@@ -393,7 +418,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button loading={isSubmitting} type="submit">
|
||||
<Button loading={isSubmitting} disabled={isSyncing} type="submit">
|
||||
{distributionMethod === DocumentDistributionMethod.EMAIL ? (
|
||||
<Trans>Send</Trans>
|
||||
) : (
|
||||
|
||||
@@ -367,7 +367,7 @@ const BillingPlanForm = ({
|
||||
<div className="w-full text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-medium">
|
||||
<Trans>Free</Trans>
|
||||
<Trans context="Plan price">Free</Trans>
|
||||
</p>
|
||||
|
||||
<Badge size="small" variant="neutral" className="ml-1.5">
|
||||
|
||||
@@ -103,8 +103,8 @@ export const TemplateBulkSendDialog = ({
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to upload CSV. Please check the file format and try again.',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
|
||||
|
||||
const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true });
|
||||
const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema;
|
||||
|
||||
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
||||
|
||||
@@ -78,7 +78,6 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
||||
eventTriggers,
|
||||
secret,
|
||||
webhookUrl,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
@@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
await deleteWebhook({ id: webhook.id, teamId: team.id });
|
||||
await deleteWebhook({ id: webhook.id });
|
||||
|
||||
toast({
|
||||
title: _(msg`Webhook deleted`),
|
||||
@@ -146,26 +146,18 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={!form.formState.isValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>I'm sure! Delete it</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={!form.formState.isValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Webhook } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZEditWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
|
||||
|
||||
const ZEditWebhookFormSchema = ZEditWebhookRequestSchema.omit({ id: true });
|
||||
|
||||
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
|
||||
|
||||
export type WebhookEditDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
webhook: Webhook;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const WebhookEditDialog = ({ trigger, webhook, ...props }: WebhookEditDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation();
|
||||
|
||||
const form = useForm<TEditWebhookFormSchema>({
|
||||
resolver: zodResolver(ZEditWebhookFormSchema),
|
||||
values: {
|
||||
webhookUrl: webhook?.webhookUrl ?? '',
|
||||
eventTriggers: webhook?.eventTriggers ?? [],
|
||||
secret: webhook?.secret ?? '',
|
||||
enabled: webhook?.enabled ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: TEditWebhookFormSchema) => {
|
||||
try {
|
||||
await updateWebhook({
|
||||
id: webhook.id,
|
||||
...data,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Webhook updated`,
|
||||
description: t`The webhook has been updated successfully.`,
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Failed to update webhook`,
|
||||
description: t`We encountered an error while updating the webhook. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
{...props}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-lg" position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Edit webhook</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>{webhook.id}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col gap-y-6"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<div className="flex flex-col-reverse gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>The URL for Documenso to send webhook events to.</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Enabled</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eventTriggers"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<FormLabel required>
|
||||
<Trans>Triggers</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<WebhookMultiSelectCombobox
|
||||
listValues={value}
|
||||
onChange={(values: string[]) => {
|
||||
onChange(values);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>The events that will trigger a webhook to be sent to your URL.</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
className="bg-background"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
A secret that will be sent to your URL so you can verify that the request
|
||||
has been sent by Documenso.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -36,8 +36,6 @@ import {
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type WebhookTestDialogProps = {
|
||||
webhook: Pick<Webhook, 'id' | 'webhookUrl' | 'eventTriggers'>;
|
||||
children: React.ReactNode;
|
||||
@@ -53,8 +51,6 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: testWebhook } = trpc.webhook.testWebhook.useMutation();
|
||||
@@ -71,7 +67,6 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
|
||||
await testWebhook({
|
||||
id: webhook.id,
|
||||
event,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -150,11 +145,11 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Send Test Webhook</Trans>
|
||||
<Trans>Send</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentData, FieldType } from '@prisma/client';
|
||||
import type { EnvelopeItem, FieldType } from '@prisma/client';
|
||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { base64 } from '@scure/base';
|
||||
import { ChevronsUpDown } from 'lucide-react';
|
||||
@@ -23,7 +24,7 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
import PDFViewer from '@documenso/ui/primitives/pdf-viewer';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -40,7 +41,8 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
|
||||
|
||||
export type ConfigureFieldsViewProps = {
|
||||
configData: TConfigureEmbedFormSchema;
|
||||
documentData?: DocumentData;
|
||||
presignToken?: string | undefined;
|
||||
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
|
||||
defaultValues?: Partial<TConfigureFieldsFormSchema>;
|
||||
onBack?: (data: TConfigureFieldsFormSchema) => void;
|
||||
onSubmit: (data: TConfigureFieldsFormSchema) => void;
|
||||
@@ -48,7 +50,8 @@ export type ConfigureFieldsViewProps = {
|
||||
|
||||
export const ConfigureFieldsView = ({
|
||||
configData,
|
||||
documentData,
|
||||
presignToken,
|
||||
envelopeItem,
|
||||
defaultValues,
|
||||
onBack,
|
||||
onSubmit,
|
||||
@@ -82,17 +85,25 @@ export const ConfigureFieldsView = ({
|
||||
}, []);
|
||||
|
||||
const normalizedDocumentData = useMemo(() => {
|
||||
if (documentData) {
|
||||
return documentData.data;
|
||||
if (envelopeItem) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!configData.documentData) {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return base64.encode(configData.documentData.data);
|
||||
}, [configData.documentData]);
|
||||
|
||||
const normalizedEnvelopeItem = useMemo(() => {
|
||||
if (envelopeItem) {
|
||||
return envelopeItem;
|
||||
}
|
||||
|
||||
return { id: '', envelopeId: '' };
|
||||
}, [envelopeItem]);
|
||||
|
||||
const recipients = useMemo(() => {
|
||||
return configData.signers.map<Recipient>((signer, index) => ({
|
||||
id: signer.nativeId || index,
|
||||
@@ -219,8 +230,8 @@ export const ConfigureFieldsView = ({
|
||||
setFieldClipboard(lastActiveField);
|
||||
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
title: _(msg`Copied field`),
|
||||
description: _(msg`Copied field to clipboard`),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -453,12 +464,12 @@ export const ConfigureFieldsView = ({
|
||||
{/* Desktop sidebar */}
|
||||
{!isMobile && (
|
||||
<div className="order-2 col-span-12 md:order-1 md:col-span-4">
|
||||
<div className="bg-widget border-border sticky top-4 max-h-[calc(100vh-2rem)] rounded-lg border p-4 pb-6">
|
||||
<div className="sticky top-4 max-h-[calc(100vh-2rem)] rounded-lg border border-border bg-widget p-4 pb-6">
|
||||
<h2 className="mb-1 text-lg font-medium">
|
||||
<Trans>Configure Fields</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground mb-6 text-sm">
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
<Trans>Configure the fields you want to place on the document.</Trans>
|
||||
</p>
|
||||
|
||||
@@ -512,7 +523,7 @@ export const ConfigureFieldsView = ({
|
||||
{selectedField && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200 [container-type:size]',
|
||||
'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size]',
|
||||
selectedRecipientStyles.base,
|
||||
{
|
||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||
@@ -534,56 +545,50 @@ export const ConfigureFieldsView = ({
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
{normalizedDocumentData && (
|
||||
<div>
|
||||
<PDFViewer
|
||||
overrideData={normalizedDocumentData}
|
||||
envelopeItem={{
|
||||
id: '',
|
||||
envelopeId: '',
|
||||
}}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
/>
|
||||
<div>
|
||||
<PDFViewerLazy
|
||||
presignToken={presignToken}
|
||||
overrideData={normalizedDocumentData}
|
||||
envelopeItem={normalizedEnvelopeItem}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
/>
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
{localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex(
|
||||
(r) => r.id === field.recipientId,
|
||||
);
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
{localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
||||
|
||||
return (
|
||||
<FieldItem
|
||||
key={field.formId}
|
||||
field={field}
|
||||
minHeight={MIN_HEIGHT_PX}
|
||||
minWidth={MIN_WIDTH_PX}
|
||||
defaultHeight={DEFAULT_HEIGHT_PX}
|
||||
defaultWidth={DEFAULT_WIDTH_PX}
|
||||
onResize={(node) => onFieldResize(node, index)}
|
||||
onMove={(node) => onFieldMove(node, index)}
|
||||
onRemove={() => remove(index)}
|
||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||
onFocus={() => setLastActiveField(field)}
|
||||
onBlur={() => setLastActiveField(null)}
|
||||
onAdvancedSettings={() => {
|
||||
setCurrentField(field);
|
||||
setShowAdvancedSettings(true);
|
||||
}}
|
||||
recipientIndex={recipientIndex}
|
||||
active={activeFieldId === field.formId}
|
||||
onFieldActivate={() => setActiveFieldId(field.formId)}
|
||||
onFieldDeactivate={() => setActiveFieldId(null)}
|
||||
disabled={selectedRecipient?.id !== field.recipientId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ElementVisible>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<FieldItem
|
||||
key={field.formId}
|
||||
field={field}
|
||||
minHeight={MIN_HEIGHT_PX}
|
||||
minWidth={MIN_WIDTH_PX}
|
||||
defaultHeight={DEFAULT_HEIGHT_PX}
|
||||
defaultWidth={DEFAULT_WIDTH_PX}
|
||||
onResize={(node) => onFieldResize(node, index)}
|
||||
onMove={(node) => onFieldMove(node, index)}
|
||||
onRemove={() => remove(index)}
|
||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||
onFocus={() => setLastActiveField(field)}
|
||||
onBlur={() => setLastActiveField(null)}
|
||||
onAdvancedSettings={() => {
|
||||
setCurrentField(field);
|
||||
setShowAdvancedSettings(true);
|
||||
}}
|
||||
recipientIndex={recipientIndex}
|
||||
active={activeFieldId === field.formId}
|
||||
onFieldActivate={() => setActiveFieldId(field.formId)}
|
||||
onFieldDeactivate={() => setActiveFieldId(null)}
|
||||
disabled={selectedRecipient?.id !== field.recipientId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ElementVisible>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -593,14 +598,14 @@ export const ConfigureFieldsView = ({
|
||||
{isMobile && (
|
||||
<Sheet open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="bg-widget border-border fixed bottom-6 left-6 right-6 z-50 flex items-center justify-between gap-2 rounded-lg border p-4">
|
||||
<div className="fixed bottom-6 left-6 right-6 z-50 flex items-center justify-between gap-2 rounded-lg border border-border bg-widget p-4">
|
||||
<span className="text-lg font-medium">
|
||||
<Trans>Configure Fields</Trans>
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="border-border text-muted-foreground inline-flex h-10 w-10 items-center justify-center rounded-lg border"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-border text-muted-foreground"
|
||||
>
|
||||
<ChevronsUpDown className="h-6 w-6" />
|
||||
</button>
|
||||
@@ -610,13 +615,13 @@ export const ConfigureFieldsView = ({
|
||||
<SheetContent
|
||||
position="bottom"
|
||||
size="xl"
|
||||
className="bg-widget h-fit max-h-[80vh] overflow-y-auto rounded-t-xl p-4"
|
||||
className="h-fit max-h-[80vh] overflow-y-auto rounded-t-xl bg-widget p-4"
|
||||
>
|
||||
<h2 className="mb-1 text-lg font-medium">
|
||||
<Trans>Configure Fields</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground mb-6 text-sm">
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
<Trans>Configure the fields you want to place on the document.</Trans>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -334,7 +334,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={recipient.token}
|
||||
version="signed"
|
||||
@@ -348,11 +348,11 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
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:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
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="flex h-fit w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||
<h3 className="text-xl font-semibold text-foreground md:text-2xl">
|
||||
<Trans>Sign document</Trans>
|
||||
</h3>
|
||||
|
||||
@@ -362,7 +362,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
className="h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground h-5 w-5" />
|
||||
<LucideChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
) : pendingFields.length > 0 ? (
|
||||
<Button
|
||||
@@ -370,7 +370,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
className="h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
|
||||
<LucideChevronUp className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -388,11 +388,11 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
</div>
|
||||
|
||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<Trans>Sign the document to complete the process.</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
<hr className="mb-8 mt-4 border-border" />
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
@@ -406,7 +406,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
className="mt-2 bg-background"
|
||||
disabled={isNameLocked}
|
||||
value={fullName}
|
||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
||||
@@ -421,7 +421,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
className="bg-background mt-2"
|
||||
className="mt-2 bg-background"
|
||||
disabled={isEmailLocked}
|
||||
value={email}
|
||||
onChange={(e) => !isEmailLocked && setEmail(e.target.value.trim())}
|
||||
@@ -490,7 +490,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
</div>
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -286,7 +286,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="embed--DocumentViewer flex-1">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
@@ -300,11 +300,11 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
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:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
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 flex w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:py-6">
|
||||
{/* Header */}
|
||||
<div className="embed--DocumentWidgetHeader">
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||
<h3 className="text-xl font-semibold text-foreground md:text-2xl">
|
||||
{isAssistantMode ? (
|
||||
<Trans>Assist with signing</Trans>
|
||||
) : (
|
||||
@@ -315,18 +315,18 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
{isExpanded ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
className="h-8 w-8 bg-background p-0 md:hidden dark:bg-foreground"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
<LucideChevronDown className="h-5 w-5 text-muted-foreground dark:text-background" />
|
||||
</Button>
|
||||
) : pendingFields.length > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
className="h-8 w-8 bg-background p-0 md:hidden dark:bg-foreground"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
<LucideChevronUp className="h-5 w-5 text-muted-foreground dark:text-background" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -346,7 +346,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
</div>
|
||||
|
||||
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{isAssistantMode ? (
|
||||
<Trans>Help complete the document for other signers.</Trans>
|
||||
) : (
|
||||
@@ -354,7 +354,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
)}
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
<hr className="mb-8 mt-4 border-border" />
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
@@ -366,7 +366,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
<Trans>Signing for</Trans>
|
||||
</Label>
|
||||
|
||||
<fieldset className="dark:bg-background border-border mt-2 rounded-2xl border bg-white p-3">
|
||||
<fieldset className="mt-2 rounded-2xl border border-border bg-white p-3 dark:bg-background">
|
||||
<RadioGroup
|
||||
className="gap-0 space-y-3 shadow-none"
|
||||
value={selectedSignerId?.toString()}
|
||||
@@ -377,7 +377,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
.map((r) => (
|
||||
<div
|
||||
key={`${assistantSignersId}-${r.id}`}
|
||||
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
|
||||
className="relative flex flex-col gap-4 rounded-lg border border-border bg-widget p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -395,15 +395,15 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
{r.name}
|
||||
|
||||
{r.id === recipient.id && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{_(msg`(You)`)}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">{r.email}</p>
|
||||
<p className="text-xs text-muted-foreground">{r.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs leading-[inherit]">
|
||||
<div className="text-xs leading-[inherit] text-muted-foreground">
|
||||
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -424,7 +424,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
className="mt-2 bg-background"
|
||||
disabled={isNameLocked}
|
||||
value={fullName}
|
||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
||||
@@ -439,7 +439,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
className="bg-background mt-2"
|
||||
className="mt-2 bg-background"
|
||||
value={email}
|
||||
disabled
|
||||
/>
|
||||
@@ -507,7 +507,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
</div>
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import PDFViewer from '@documenso/ui/primitives/pdf-viewer';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -150,8 +150,8 @@ export const MultiSignDocumentSigningView = ({
|
||||
onDocumentError?.();
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to complete the document. Please try again.',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to complete the document. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
@@ -177,14 +177,14 @@ export const MultiSignDocumentSigningView = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-screen overflow-hidden">
|
||||
<div className="min-h-screen overflow-hidden bg-background">
|
||||
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
|
||||
{match({ isLoading, document })
|
||||
.with({ isLoading: true }, () => (
|
||||
<div className="flex min-h-[400px] w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader className="text-primary h-8 w-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Loader className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Loading document...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@@ -192,7 +192,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
))
|
||||
.with({ isLoading: false, document: undefined }, () => (
|
||||
<div className="flex min-h-[400px] w-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Failed to load document</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@@ -225,7 +225,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
|
||||
})}
|
||||
>
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
@@ -243,23 +243,23 @@ export const MultiSignDocumentSigningView = ({
|
||||
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:bottom-[unset] md:top-0 md:z-auto md:w-[350px] md:px-0"
|
||||
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 flex w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:py-6">
|
||||
{/* Header */}
|
||||
<div className="embed--DocumentWidgetHeader">
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||
<h3 className="text-xl font-semibold text-foreground md:text-2xl">
|
||||
<Trans>Sign document</Trans>
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
@@ -268,11 +268,11 @@ export const MultiSignDocumentSigningView = ({
|
||||
</div>
|
||||
|
||||
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<Trans>Sign the document to complete the process.</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
<hr className="mb-8 mt-4 border-border" />
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
@@ -288,7 +288,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
className="mt-2 bg-background"
|
||||
disabled={isNameLocked}
|
||||
value={fullName}
|
||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
||||
@@ -303,7 +303,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
className="bg-background mt-2"
|
||||
className="mt-2 bg-background"
|
||||
value={email}
|
||||
disabled
|
||||
/>
|
||||
|
||||
@@ -58,6 +58,7 @@ export type TDocumentPreferencesFormSchema = {
|
||||
includeSigningCertificate: boolean | null;
|
||||
includeAuditLog: boolean | null;
|
||||
signatureTypes: DocumentSignatureType[];
|
||||
aiFeaturesEnabled: boolean | null;
|
||||
};
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
@@ -72,11 +73,13 @@ type SettingsSubset = Pick<
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
| 'aiFeaturesEnabled'
|
||||
>;
|
||||
|
||||
export type DocumentPreferencesFormProps = {
|
||||
settings: SettingsSubset;
|
||||
canInherit: boolean;
|
||||
isAiFeaturesConfigured?: boolean;
|
||||
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -84,6 +87,7 @@ export const DocumentPreferencesForm = ({
|
||||
settings,
|
||||
onFormSubmit,
|
||||
canInherit,
|
||||
isAiFeaturesConfigured = false,
|
||||
}: DocumentPreferencesFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { user, organisations } = useSession();
|
||||
@@ -105,6 +109,7 @@ export const DocumentPreferencesForm = ({
|
||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
aiFeaturesEnabled: z.boolean().nullable(),
|
||||
});
|
||||
|
||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||
@@ -120,6 +125,7 @@ export const DocumentPreferencesForm = ({
|
||||
includeSigningCertificate: settings.includeSigningCertificate,
|
||||
includeAuditLog: settings.includeAuditLog,
|
||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
},
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
});
|
||||
@@ -312,7 +318,7 @@ export const DocumentPreferencesForm = ({
|
||||
}))}
|
||||
selectedValues={field.value}
|
||||
onChange={field.onChange}
|
||||
className="bg-background w-full"
|
||||
className="w-full bg-background"
|
||||
enableSearch={false}
|
||||
emptySelectionPlaceholder={
|
||||
canInherit ? t`Inherit from organisation` : t`Select signature types`
|
||||
@@ -378,7 +384,7 @@ export const DocumentPreferencesForm = ({
|
||||
</FormControl>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>Preview</Trans>
|
||||
</div>
|
||||
|
||||
@@ -509,6 +515,59 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAiFeaturesConfigured && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="aiFeaturesEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>AI Features</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Enabled</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>Disabled</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Enable AI-powered features such as automatic recipient detection. When
|
||||
enabled, document content will be sent to AI providers. We only use providers
|
||||
that do not retain data for training and prefer European regions where
|
||||
available.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
|
||||
@@ -201,7 +201,7 @@ export const SignInForm = ({
|
||||
.otherwise(() => handleFallbackErrorMessages(error.code));
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(errorMessage),
|
||||
duration: 10000,
|
||||
variant: 'destructive',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type AnimatedDocumentScannerProps = {
|
||||
className?: string;
|
||||
interval?: number;
|
||||
};
|
||||
|
||||
export const AnimatedDocumentScanner = ({
|
||||
className,
|
||||
interval = 2500,
|
||||
}: AnimatedDocumentScannerProps) => {
|
||||
const [magPosition, setMagPosition] = useState({ x: 0, y: 0, page: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const moveInterval = setInterval(() => {
|
||||
setMagPosition({
|
||||
x: Math.random() * 60 - 30,
|
||||
y: Math.random() * 50 - 25,
|
||||
page: Math.random() > 0.5 ? 1 : 0,
|
||||
});
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(moveInterval);
|
||||
}, [interval]);
|
||||
|
||||
return (
|
||||
<div className={cn('relative mx-auto h-36 w-56', className)}>
|
||||
{/* Magnifying glass */}
|
||||
<div
|
||||
className="pointer-events-none absolute z-50 transition-all duration-1000 ease-in-out"
|
||||
style={{
|
||||
left: magPosition.page === 0 ? '25%' : '75%',
|
||||
top: '50%',
|
||||
transform: `translate(calc(-50% + ${magPosition.x}px), calc(-50% + ${magPosition.y}px))`,
|
||||
}}
|
||||
>
|
||||
<SearchIcon className="h-8 w-8 text-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Book container */}
|
||||
<div className="relative h-full w-full animate-pulse" style={{ perspective: '800px' }}>
|
||||
<div className="relative h-full w-full" style={{ transformStyle: 'preserve-3d' }}>
|
||||
{/* Left page */}
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full w-[calc(50%)] origin-right overflow-hidden rounded-l-md border border-border bg-card shadow-md"
|
||||
style={{ transform: 'rotateY(15deg) skewY(-1deg)' }}
|
||||
>
|
||||
<div className="absolute inset-3 space-y-2">
|
||||
<div className="h-1.5 w-3/4 rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-full rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-5/6 rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-2/3 rounded-sm bg-muted" />
|
||||
<div className="mt-3 h-6 w-3/4 rounded border border-dashed border-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right page */}
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-[calc(50%)] origin-left overflow-hidden rounded-r-md border border-border bg-card shadow-md"
|
||||
style={{ transform: 'rotateY(-15deg) skewY(1deg)' }}
|
||||
>
|
||||
<div className="absolute inset-3 space-y-2">
|
||||
<div className="h-1.5 w-full rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-4/5 rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-full rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-3/5 rounded-sm bg-muted" />
|
||||
<div className="mt-3 h-6 w-2/3 rounded border border-dashed border-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -42,7 +42,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { ZCreateOrganisationFormSchema } from '../dialogs/organisation-create-dialog';
|
||||
|
||||
const MotionCard = motion(Card);
|
||||
const MotionCard = motion.create(Card);
|
||||
|
||||
export type BillingPlansProps = {
|
||||
plans: InternalClaimPlans;
|
||||
@@ -101,7 +101,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
||||
<CardContent className="flex h-full flex-col p-6">
|
||||
<CardTitle>{price.product.name}</CardTitle>
|
||||
|
||||
<div className="text-muted-foreground mt-2 text-lg font-medium">
|
||||
<div className="mt-2 text-lg font-medium text-muted-foreground">
|
||||
{price.friendlyPrice + ' '}
|
||||
<span className="text-xs">
|
||||
{interval === 'monthlyPrice' ? (
|
||||
@@ -112,12 +112,12 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-1.5 text-sm">
|
||||
<div className="mt-1.5 text-sm text-muted-foreground">
|
||||
{price.product.description}
|
||||
</div>
|
||||
|
||||
{price.product.features && price.product.features.length > 0 && (
|
||||
<div className="text-muted-foreground mt-4">
|
||||
<div className="mt-4 text-muted-foreground">
|
||||
<div className="text-sm font-medium">Includes:</div>
|
||||
|
||||
<ul className="mt-1 divide-y text-sm">
|
||||
@@ -261,7 +261,7 @@ const BillingDialog = ({
|
||||
<Building2Icon className="h-4 w-4" />
|
||||
<Trans>Update current organisation</Trans>
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Upgrade <strong>{organisation.name}</strong> to {planName}
|
||||
</Trans>
|
||||
@@ -276,7 +276,7 @@ const BillingDialog = ({
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
<Trans>Create separate organisation</Trans>
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Create a new organisation with {planName} plan. Keep your current organisation
|
||||
on it's current plan
|
||||
|
||||
@@ -13,7 +13,7 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -151,7 +151,7 @@ export const DirectTemplatePageView = ({
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
key={template.id}
|
||||
envelopeItem={template.envelopeItems[0]}
|
||||
token={directTemplateRecipient.token}
|
||||
|
||||
@@ -108,8 +108,8 @@ export const DocumentSigningForm = ({
|
||||
await completeDocument({ nextSigner });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while completing the document. Please try again.',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while completing the document. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@@ -135,7 +135,7 @@ export const DocumentSigningForm = ({
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
@@ -166,7 +166,7 @@ export const DocumentSigningForm = ({
|
||||
) : recipient.role === RecipientRole.ASSISTANT ? (
|
||||
<>
|
||||
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
|
||||
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
|
||||
<fieldset className="rounded-2xl border border-border bg-white p-3 dark:bg-background">
|
||||
<Controller
|
||||
name="selectedSignerId"
|
||||
control={assistantForm.control}
|
||||
@@ -185,7 +185,7 @@ export const DocumentSigningForm = ({
|
||||
.map((r) => (
|
||||
<div
|
||||
key={`${assistantSignersId}-${r.id}`}
|
||||
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
|
||||
className="relative flex flex-col gap-4 rounded-lg border border-border bg-widget p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -203,15 +203,15 @@ export const DocumentSigningForm = ({
|
||||
{r.name}
|
||||
|
||||
{r.id === recipient.id && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{_(msg`(You)`)}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">{r.email}</p>
|
||||
<p className="text-xs text-muted-foreground">{r.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs leading-[inherit]">
|
||||
<div className="text-xs leading-[inherit] text-muted-foreground">
|
||||
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,7 +265,7 @@ export const DocumentSigningForm = ({
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
className="mt-2 bg-background"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
@@ -294,7 +294,7 @@ export const DocumentSigningForm = ({
|
||||
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
|
||||
+46
-17
@@ -30,7 +30,7 @@ import { DocumentReadOnlyFields } from '@documenso/ui/components/document/docume
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
|
||||
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
|
||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||
@@ -187,45 +187,74 @@ export const DocumentSigningPageViewV1 = ({
|
||||
|
||||
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-y-2 sm:mt-2.5 sm:gap-y-0">
|
||||
<div className="max-w-[50ch]">
|
||||
<span className="text-muted-foreground truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () =>
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
on behalf of "{document.team?.name}" has invited you to view this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to view this document</Trans>
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
has invited you to view this document
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.SIGNER, () =>
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
on behalf of "{document.team?.name}" has invited you to sign this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to sign this document</Trans>
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
has invited you to sign this document
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.APPROVER, () =>
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
on behalf of "{document.team?.name}" has invited you to approve this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to approve this document</Trans>
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
has invited you to approve this document
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.ASSISTANT, () =>
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
on behalf of "{document.team?.name}" has invited you to assist this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to assist this document</Trans>
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
has invited you to assist this document
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.otherwise(() => null)}
|
||||
@@ -245,7 +274,7 @@ export const DocumentSigningPageViewV1 = ({
|
||||
<div className="flex-1">
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
key={document.envelopeItems[0].id}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={recipient.token}
|
||||
@@ -260,9 +289,9 @@ export const DocumentSigningPageViewV1 = ({
|
||||
className="group/document-widget fixed bottom-6 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-4 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
<div className="flex w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:py-6">
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||
<h3 className="text-xl font-semibold text-foreground md:text-2xl">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
@@ -305,25 +334,25 @@ export const DocumentSigningPageViewV1 = ({
|
||||
.with({ isExpanded: true }, () => (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
className="h-8 w-8 bg-background p-0 md:hidden dark:bg-foreground"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
<LucideChevronDown className="h-5 w-5 text-muted-foreground dark:text-background" />
|
||||
</Button>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
className="h-8 w-8 bg-background p-0 md:hidden dark:bg-foreground"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
<LucideChevronUp className="h-5 w-5 text-muted-foreground dark:text-background" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<Trans>Please mark as viewed to complete.</Trans>
|
||||
@@ -340,7 +369,7 @@ export const DocumentSigningPageViewV1 = ({
|
||||
.otherwise(() => null)}
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
<hr className="mb-8 mt-4 border-border" />
|
||||
</div>
|
||||
|
||||
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
||||
|
||||
+11
-11
@@ -34,7 +34,7 @@ import { DocumentSigningRejectDialog } from './document-signing-reject-dialog';
|
||||
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
||||
|
||||
const EnvelopeSignerPageRenderer = lazy(
|
||||
async () => import('../envelope-signing/envelope-signer-page-renderer'),
|
||||
async () => import('~/components/general/envelope-signing/envelope-signer-page-renderer'),
|
||||
);
|
||||
|
||||
export const DocumentSigningPageViewV2 = () => {
|
||||
@@ -71,7 +71,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
}, [recipientFieldsRemaining, selectedAssistantRecipientFields, currentEnvelopeItem]);
|
||||
|
||||
return (
|
||||
<div className="dark:bg-background min-h-screen w-screen bg-gray-50">
|
||||
<div className="min-h-screen w-screen bg-gray-50 dark:bg-background">
|
||||
<SignFieldEmailDialog.Root />
|
||||
<SignFieldTextDialog.Root />
|
||||
<SignFieldNumberDialog.Root />
|
||||
@@ -86,9 +86,9 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
{/* Main Content Area */}
|
||||
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||
{/* Left Section - Step Navigation */}
|
||||
<div className="embed--DocumentWidgetContainer bg-background border-border hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4 lg:flex">
|
||||
<div className="embed--DocumentWidgetContainer hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4 lg:flex">
|
||||
<div className="px-4">
|
||||
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
|
||||
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
@@ -96,7 +96,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||
.otherwise(() => null)}
|
||||
|
||||
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
|
||||
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Plural
|
||||
value={recipientFieldsRemaining.length}
|
||||
one="1 Field Remaining"
|
||||
@@ -105,11 +105,11 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="bg-muted relative my-4 h-[4px] rounded-md">
|
||||
<div className="relative my-4 h-[4px] rounded-md bg-muted">
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="bg-documenso absolute inset-y-0 left-0"
|
||||
className="absolute inset-y-0 left-0 bg-documenso"
|
||||
style={{
|
||||
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||
}}
|
||||
@@ -126,7 +126,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
{/* Quick Actions. */}
|
||||
{!isDirectTemplate && (
|
||||
<div className="embed--Actions space-y-3 px-4">
|
||||
<h4 className="text-foreground text-sm font-semibold">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
<Trans>Actions</Trans>
|
||||
</h4>
|
||||
|
||||
@@ -173,7 +173,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover:text-destructive w-full justify-start"
|
||||
className="w-full justify-start hover:text-destructive"
|
||||
>
|
||||
<BanIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Reject Document</Trans>
|
||||
@@ -235,7 +235,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<p className="text-foreground text-sm">
|
||||
<p className="text-sm text-foreground">
|
||||
<Trans>No documents found</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@@ -250,7 +250,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
<a
|
||||
href="https://documenso.com"
|
||||
target="_blank"
|
||||
className="bg-primary text-primary-foreground fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:block"
|
||||
className="fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:block"
|
||||
>
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
|
||||
+4
-4
@@ -74,8 +74,8 @@ export function DocumentSigningRejectDialog({
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Document rejected',
|
||||
description: 'The document has been successfully rejected.',
|
||||
title: t`Document rejected`,
|
||||
description: t`The document has been successfully rejected.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@@ -88,8 +88,8 @@ export function DocumentSigningRejectDialog({
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while rejecting the document. Please try again.',
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while rejecting the document. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { lazy, useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
|
||||
@@ -21,12 +21,15 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
|
||||
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
|
||||
import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer';
|
||||
|
||||
const EnvelopeGenericPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
|
||||
);
|
||||
|
||||
export type DocumentCertificateQRViewProps = {
|
||||
documentId: number;
|
||||
@@ -120,7 +123,7 @@ export const DocumentCertificateQRView = ({
|
||||
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
<div className="text-muted-foreground flex flex-col gap-0.5 text-sm">
|
||||
<div className="flex flex-col gap-0.5 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<Trans>{recipientCount} recipients</Trans>
|
||||
</p>
|
||||
@@ -146,7 +149,7 @@ export const DocumentCertificateQRView = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-12 w-full">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
key={envelopeItems[0].id}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
@@ -179,7 +182,7 @@ const DocumentCertificateQrV2 = ({
|
||||
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
<div className="text-muted-foreground flex flex-col gap-0.5 text-sm">
|
||||
<div className="flex flex-col gap-0.5 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<Trans>{recipientCount} recipients</Trans>
|
||||
</p>
|
||||
|
||||
@@ -27,7 +27,7 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
|
||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -440,7 +440,7 @@ export const DocumentEditForm = ({
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
key={document.envelopeItems[0].id}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={undefined}
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import { lazy, useEffect, useMemo } from 'react';
|
||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type {
|
||||
TCheckboxFieldMeta,
|
||||
TDateFieldMeta,
|
||||
TDropdownFieldMeta,
|
||||
TEmailFieldMeta,
|
||||
TFieldMetaSchema,
|
||||
TInitialsFieldMeta,
|
||||
TNameFieldMeta,
|
||||
TNumberFieldMeta,
|
||||
TRadioFieldMeta,
|
||||
TSignatureFieldMeta,
|
||||
TTextFieldMeta,
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import {
|
||||
FIELD_META_DEFAULT_VALUES,
|
||||
type TCheckboxFieldMeta,
|
||||
type TDateFieldMeta,
|
||||
type TDropdownFieldMeta,
|
||||
type TEmailFieldMeta,
|
||||
type TFieldMetaSchema,
|
||||
type TInitialsFieldMeta,
|
||||
type TNameFieldMeta,
|
||||
type TNumberFieldMeta,
|
||||
type TRadioFieldMeta,
|
||||
type TSignatureFieldMeta,
|
||||
type TTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
@@ -31,6 +34,7 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
|
||||
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
||||
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
||||
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
|
||||
@@ -41,13 +45,14 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
|
||||
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
||||
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
|
||||
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
||||
|
||||
const EnvelopeEditorFieldsPageRenderer = lazy(
|
||||
async () => import('./envelope-editor-fields-page-renderer'),
|
||||
async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
|
||||
);
|
||||
|
||||
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
@@ -67,11 +72,15 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
export const EnvelopeEditorFieldsPage = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||
|
||||
const selectedField = useMemo(
|
||||
() => structuredClone(editorFields.selectedField),
|
||||
@@ -96,6 +105,24 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onFieldDetectionComplete = (fields: NormalizedFieldWithContext[]) => {
|
||||
for (const field of fields) {
|
||||
editorFields.addField({
|
||||
height: field.height,
|
||||
width: field.width,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
type: field.type,
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
recipientId: field.recipientId,
|
||||
page: field.pageNumber,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[field.type]),
|
||||
});
|
||||
}
|
||||
|
||||
setIsAiFieldDialogOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the selected recipient to the first recipient in the envelope.
|
||||
*/
|
||||
@@ -119,7 +146,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
{envelope.recipients.length === 0 && (
|
||||
<Alert
|
||||
variant="neutral"
|
||||
className="border-border bg-background mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border"
|
||||
className="mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border border-border bg-background"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<AlertTitle>
|
||||
@@ -145,11 +172,11 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
||||
<p className="text-foreground mt-1 text-sm">
|
||||
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
|
||||
<p className="mt-1 text-sm text-foreground">
|
||||
<Trans>No documents found</Trans>
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<Trans>Please upload a document to continue</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@@ -159,10 +186,10 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
{/* Right Section - Form Fields Panel */}
|
||||
{currentEnvelopeItem && envelope.recipients.length > 0 && (
|
||||
<div className="bg-background border-border sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l py-4">
|
||||
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l border-border bg-background py-4">
|
||||
{/* Recipient selector section. */}
|
||||
<section className="px-4">
|
||||
<h3 className="text-foreground mb-2 text-sm font-semibold">
|
||||
<h3 className="mb-2 text-sm font-semibold text-foreground">
|
||||
<Trans>Selected Recipient</Trans>
|
||||
</h3>
|
||||
|
||||
@@ -194,7 +221,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
{/* Add fields section. */}
|
||||
<section className="px-4">
|
||||
<h3 className="text-foreground mb-2 text-sm font-semibold">
|
||||
<h3 className="mb-2 text-sm font-semibold text-foreground">
|
||||
<Trans>Add Fields</Trans>
|
||||
</h3>
|
||||
|
||||
@@ -202,6 +229,35 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
||||
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
||||
/>
|
||||
|
||||
{team.preferences.aiFeaturesEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 w-full"
|
||||
onClick={() => setIsAiFieldDialogOpen(true)}
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||
title={
|
||||
envelope.status !== DocumentStatus.DRAFT
|
||||
? _(msg`You can only detect fields in draft envelopes`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Detect with AI</Trans>
|
||||
</Button>
|
||||
|
||||
<AiFieldDetectionDialog
|
||||
open={isAiFieldDialogOpen}
|
||||
onOpenChange={setIsAiFieldDialogOpen}
|
||||
onComplete={onFieldDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Field details section. */}
|
||||
@@ -213,25 +269,25 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
{searchParams.get('devmode') && (
|
||||
<>
|
||||
<div className="px-4">
|
||||
<h3 className="text-foreground mb-3 text-sm font-semibold">
|
||||
<h3 className="mb-3 text-sm font-semibold text-foreground">
|
||||
<Trans>Developer Mode</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="bg-muted/50 border-border text-foreground space-y-2 rounded-md border p-3 text-sm">
|
||||
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-sm text-foreground">
|
||||
<p>
|
||||
<span className="text-muted-foreground min-w-12">Pos X: </span>
|
||||
<span className="min-w-12 text-muted-foreground">Pos X: </span>
|
||||
{selectedField.positionX.toFixed(2)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground min-w-12">Pos Y: </span>
|
||||
<span className="min-w-12 text-muted-foreground">Pos Y: </span>
|
||||
{selectedField.positionY.toFixed(2)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground min-w-12">Width: </span>
|
||||
<span className="min-w-12 text-muted-foreground">Width: </span>
|
||||
{selectedField.width.toFixed(2)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground min-w-12">Height: </span>
|
||||
<span className="min-w-12 text-muted-foreground">Height: </span>
|
||||
{selectedField.height.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -241,9 +297,9 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="[&_label]:text-foreground/70 px-4 [&_label]:text-xs">
|
||||
<div className="px-4 [&_label]:text-xs [&_label]:text-foreground/70">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(FieldSettingsTypeTranslations[selectedField.type])}
|
||||
{_(FieldSettingsTypeTranslations[selectedField.type])}
|
||||
</h3>
|
||||
|
||||
{match(selectedField.type)
|
||||
|
||||
@@ -30,18 +30,11 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||
export default function EnvelopeEditorHeader() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const {
|
||||
envelope,
|
||||
isDocument,
|
||||
isTemplate,
|
||||
updateEnvelope,
|
||||
autosaveError,
|
||||
relativePath,
|
||||
editorFields,
|
||||
} = useCurrentEnvelopeEditor();
|
||||
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError, relativePath } =
|
||||
useCurrentEnvelopeEditor();
|
||||
|
||||
return (
|
||||
<nav className="bg-background border-border w-full border-b px-4 py-3 md:px-6">
|
||||
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link to="/">
|
||||
@@ -147,10 +140,6 @@ export default function EnvelopeEditorHeader() {
|
||||
{isDocument && (
|
||||
<>
|
||||
<EnvelopeDistributeDialog
|
||||
envelope={{
|
||||
...envelope,
|
||||
fields: editorFields.localFields,
|
||||
}}
|
||||
documentRootPath={relativePath.documentRootPath}
|
||||
trigger={
|
||||
<Button size="sm">
|
||||
|
||||
@@ -23,7 +23,9 @@ import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
|
||||
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
|
||||
const EnvelopeGenericPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
|
||||
);
|
||||
|
||||
// Todo: Envelopes - Dynamically import faker
|
||||
export const EnvelopeEditorPreviewPage = () => {
|
||||
@@ -232,11 +234,11 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
||||
<p className="text-foreground mt-1 text-sm">
|
||||
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
|
||||
<p className="mt-1 text-sm text-foreground">
|
||||
<Trans>No documents found</Trans>
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<Trans>Please upload a document to continue</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+134
-8
@@ -12,8 +12,9 @@ import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
|
||||
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
|
||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { isDeepEqual, prop, sortBy } from 'remeda';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -22,6 +23,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
import {
|
||||
ZRecipientActionAuthTypesSchema,
|
||||
ZRecipientAuthOptionsSchema,
|
||||
@@ -60,6 +62,9 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
const ZEnvelopeRecipientsForm = z.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
@@ -85,14 +90,36 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { remaining } = useLimits();
|
||||
const { user } = useSession();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
||||
|
||||
// AI recipient detection dialog state
|
||||
const [isAiDialogOpen, setIsAiDialogOpen] = useState(() => searchParams.get('ai') === 'true');
|
||||
|
||||
const onAiDialogOpenChange = (open: boolean) => {
|
||||
setIsAiDialogOpen(open);
|
||||
|
||||
if (!open && searchParams.get('ai') === 'true') {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
|
||||
newParams.delete('ai');
|
||||
|
||||
return newParams;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||
|
||||
const initialId = useId();
|
||||
@@ -244,6 +271,71 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const onAiDetectionComplete = (detectedRecipients: TDetectedRecipientSchema[]) => {
|
||||
const currentSigners = form.getValues('signers');
|
||||
|
||||
let nextSigningOrder =
|
||||
currentSigners.length > 0
|
||||
? Math.max(...currentSigners.map((s) => s.signingOrder ?? 0)) + 1
|
||||
: 1;
|
||||
|
||||
// If the only signer is the default empty signer lets just replace it with the detected recipients
|
||||
if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) {
|
||||
form.setValue(
|
||||
'signers',
|
||||
detectedRecipients.map((recipient, index) => ({
|
||||
formId: nanoid(12),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
actionAuth: [],
|
||||
signingOrder: index + 1,
|
||||
})),
|
||||
{
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const recipient of detectedRecipients) {
|
||||
const emailExists = currentSigners.some(
|
||||
(s) => s.email.toLowerCase() === recipient.email.toLowerCase(),
|
||||
);
|
||||
|
||||
const nameExists = currentSigners.some(
|
||||
(s) => s.name.toLowerCase() === recipient.name.toLowerCase(),
|
||||
);
|
||||
|
||||
if ((emailExists && recipient.email) || (nameExists && recipient.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
currentSigners.push({
|
||||
formId: nanoid(12),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
actionAuth: [],
|
||||
signingOrder: nextSigningOrder,
|
||||
});
|
||||
|
||||
nextSigningOrder += 1;
|
||||
}
|
||||
|
||||
form.setValue('signers', normalizeSigningOrders(currentSigners), {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Recipients added`,
|
||||
description: t`${detectedRecipients.length} recipient(s) have been added from AI detection.`,
|
||||
});
|
||||
};
|
||||
|
||||
const onRemoveSigner = (index: number) => {
|
||||
const signer = signers[index];
|
||||
|
||||
@@ -306,8 +398,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
index: number,
|
||||
suggestion: RecipientAutoCompleteOption,
|
||||
) => {
|
||||
setValue(`signers.${index}.email`, suggestion.email);
|
||||
setValue(`signers.${index}.name`, suggestion.name || '');
|
||||
setValue(`signers.${index}.email`, suggestion.email, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
setValue(`signers.${index}.name`, suggestion.name || '', {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
};
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
@@ -543,6 +641,26 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
{team.preferences.aiFeaturesEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => setIsAiDialogOpen(true)}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
<Trans>Detect recipients with AI</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-row items-center"
|
||||
@@ -570,7 +688,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
<CardContent>
|
||||
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
||||
<Form {...form}>
|
||||
<div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4">
|
||||
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
|
||||
{organisation.organisationClaim.flags.cfr21 && (
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
@@ -634,7 +752,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground ml-1 cursor-help">
|
||||
<span className="ml-1 cursor-help text-muted-foreground">
|
||||
<HelpCircleIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -679,7 +797,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground ml-1 cursor-help">
|
||||
<span className="ml-1 cursor-help text-muted-foreground">
|
||||
<HelpCircleIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -716,7 +834,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
>
|
||||
{signers.map((signer, index) => (
|
||||
<Draggable
|
||||
key={`${signer.id}-${signer.signingOrder}`}
|
||||
key={`${signer.nativeId}-${signer.signingOrder}`}
|
||||
draggableId={signer['nativeId']}
|
||||
index={index}
|
||||
isDragDisabled={
|
||||
@@ -732,7 +850,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={cn('py-1', {
|
||||
'bg-widget-foreground pointer-events-none rounded-md pt-2':
|
||||
'pointer-events-none rounded-md bg-widget-foreground pt-2':
|
||||
snapshot.isDragging,
|
||||
})}
|
||||
>
|
||||
@@ -992,6 +1110,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
onOpenChange={setShowSigningOrderConfirmation}
|
||||
onConfirm={handleSigningOrderDisable}
|
||||
/>
|
||||
|
||||
<AiRecipientDetectionDialog
|
||||
open={isAiDialogOpen}
|
||||
onOpenChange={onAiDialogOpenChange}
|
||||
onComplete={onAiDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||
import type { DropResult } from '@hello-pangea/dnd';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { msg, plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
||||
@@ -226,7 +226,12 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
}
|
||||
|
||||
if (maximumEnvelopeItemCount <= localFiles.length) {
|
||||
return msg`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`;
|
||||
return msg({
|
||||
message: plural(maximumEnvelopeItemCount, {
|
||||
one: `You cannot upload more than # item per envelope.`,
|
||||
other: `You cannot upload more than # items per envelope.`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -240,7 +245,10 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
|
||||
if (maxItemsReached) {
|
||||
toast({
|
||||
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
|
||||
title: plural(maximumEnvelopeItemCount, {
|
||||
one: `You cannot upload more than # item per envelope.`,
|
||||
other: `You cannot upload more than # items per envelope.`,
|
||||
}),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@@ -152,30 +152,30 @@ export default function EnvelopeEditor() {
|
||||
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
|
||||
|
||||
return (
|
||||
<div className="dark:bg-background h-screen w-screen bg-gray-50">
|
||||
<div className="h-screen w-screen bg-gray-50 dark:bg-background">
|
||||
<EnvelopeEditorHeader />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||
{/* Left Section - Step Navigation */}
|
||||
<div className="bg-background border-border flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4">
|
||||
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4">
|
||||
{/* Left section step selector. */}
|
||||
<div className="px-4">
|
||||
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
|
||||
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
|
||||
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
||||
|
||||
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
|
||||
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Trans context="The step counter">
|
||||
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
||||
</Trans>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="bg-muted relative my-4 h-[4px] rounded-md">
|
||||
<div className="relative my-4 h-[4px] rounded-md bg-muted">
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="bg-documenso absolute inset-y-0 left-0"
|
||||
className="absolute inset-y-0 left-0 bg-documenso"
|
||||
style={{
|
||||
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
|
||||
}}
|
||||
@@ -219,7 +219,7 @@ export default function EnvelopeEditor() {
|
||||
>
|
||||
{t(step.title)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">{t(step.description)}</div>
|
||||
<div className="text-xs text-muted-foreground">{t(step.description)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,7 +232,7 @@ export default function EnvelopeEditor() {
|
||||
|
||||
{/* Quick Actions. */}
|
||||
<div className="space-y-3 px-4">
|
||||
<h4 className="text-foreground text-sm font-semibold">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
<Trans>Quick Actions</Trans>
|
||||
</h4>
|
||||
<EnvelopeEditorSettingsDialog
|
||||
@@ -246,10 +246,6 @@ export default function EnvelopeEditor() {
|
||||
|
||||
{isDocument && (
|
||||
<EnvelopeDistributeDialog
|
||||
envelope={{
|
||||
...envelope,
|
||||
fields: editorFields.localFields,
|
||||
}}
|
||||
documentRootPath={relativePath.documentRootPath}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
|
||||
+1
-1
@@ -171,7 +171,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
|
||||
const handleFieldGroupClick = (e: KonvaEventObject<Event>) => {
|
||||
const currentTarget = e.currentTarget as Konva.Group;
|
||||
const target = e.target;
|
||||
const target = e.target as Konva.Shape;
|
||||
|
||||
const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect();
|
||||
|
||||
|
||||
+14
-7
@@ -57,17 +57,24 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextField.envelopeItemId !== currentEnvelopeItem?.id) {
|
||||
const isEnvelopeItemSwitch = nextField.envelopeItemId !== currentEnvelopeItem?.id;
|
||||
|
||||
if (isEnvelopeItemSwitch) {
|
||||
setCurrentEnvelopeItem(nextField.envelopeItemId);
|
||||
}
|
||||
|
||||
const fieldTooltip = document.querySelector(`#field-tooltip`);
|
||||
|
||||
if (fieldTooltip) {
|
||||
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
setShowPendingFieldTooltip(true);
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
const fieldTooltip = document.querySelector(`#field-tooltip`);
|
||||
|
||||
if (fieldTooltip) {
|
||||
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
},
|
||||
isEnvelopeItemSwitch ? 150 : 50,
|
||||
);
|
||||
};
|
||||
|
||||
const handleOnCompleteClick = async (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
@@ -115,7 +116,9 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit`);
|
||||
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@@ -153,7 +156,10 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
|
||||
if (maxItemsReached) {
|
||||
toast({
|
||||
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
|
||||
title: plural(maximumEnvelopeItemCount, {
|
||||
one: `You cannot upload more than # item per envelope.`,
|
||||
other: `You cannot upload more than # items per envelope.`,
|
||||
}),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -220,9 +226,9 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
{children}
|
||||
|
||||
{isDragActive && (
|
||||
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
||||
<div className="fixed left-0 top-0 z-[9999] h-full w-full bg-muted/60 backdrop-blur-[4px]">
|
||||
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
||||
<h2 className="text-foreground text-2xl font-semibold">
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
{type === EnvelopeType.DOCUMENT ? (
|
||||
<Trans>Upload Document</Trans>
|
||||
) : (
|
||||
@@ -230,7 +236,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground text-md mt-4">
|
||||
<p className="text-md mt-4 text-muted-foreground">
|
||||
<Trans>Drag and drop your PDF file here</Trans>
|
||||
</p>
|
||||
|
||||
@@ -247,7 +253,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/80 mt-4 text-sm">
|
||||
<p className="mt-4 text-sm text-muted-foreground/80">
|
||||
<Trans>
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</Trans>
|
||||
@@ -258,10 +264,10 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
|
||||
<div className="absolute inset-0 z-50 bg-muted/30 backdrop-blur-[2px]">
|
||||
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
||||
<Loader className="text-primary h-12 w-12 animate-spin" />
|
||||
<p className="text-foreground mt-8 font-medium">
|
||||
<Loader className="h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-8 font-medium text-foreground">
|
||||
<Trans>Uploading</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { msg, plural } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
@@ -108,7 +108,9 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit`);
|
||||
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
|
||||
|
||||
toast({
|
||||
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
|
||||
@@ -153,7 +155,10 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
|
||||
if (maxItemsReached) {
|
||||
toast({
|
||||
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
|
||||
title: plural(maximumEnvelopeItemCount, {
|
||||
one: `You cannot upload more than # item per envelope.`,
|
||||
other: `You cannot upload more than # items per envelope.`,
|
||||
}),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
|
||||
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
||||
@@ -312,7 +312,7 @@ export const TemplateEditForm = ({
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
key={template.envelopeItems[0].id}
|
||||
envelopeItem={template.envelopeItems[0]}
|
||||
token={undefined}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { WebhookCallStatus } from '@prisma/client';
|
||||
import { RotateCwIcon } from 'lucide-react';
|
||||
import { createCallable } from 'react-call';
|
||||
|
||||
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindWebhookCallsResponse } from '@documenso/trpc/server/webhook-router/find-webhook-calls.types';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type WebhookLogsSheetProps = {
|
||||
webhookCall: TFindWebhookCallsResponse['data'][number];
|
||||
};
|
||||
|
||||
export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | null>(
|
||||
({ call, webhookCall: initialWebhookCall }) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [webhookCall, setWebhookCall] = useState(initialWebhookCall);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'request' | 'response'>('request');
|
||||
|
||||
const { mutateAsync: resendWebhookCall, isPending: isResending } =
|
||||
trpc.webhook.calls.resend.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast({ title: t`Webhook successfully sent` });
|
||||
|
||||
setWebhookCall(result);
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: t`Something went wrong` });
|
||||
},
|
||||
});
|
||||
|
||||
const generalWebhookDetails = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: t`Status`,
|
||||
value: webhookCall.status === WebhookCallStatus.SUCCESS ? t`Success` : t`Failed`,
|
||||
},
|
||||
{
|
||||
header: t`Event`,
|
||||
value: toFriendlyWebhookEventName(webhookCall.event),
|
||||
},
|
||||
{
|
||||
header: t`Sent`,
|
||||
value: new Date(webhookCall.createdAt).toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: t`Response Code`,
|
||||
value: webhookCall.responseCode,
|
||||
},
|
||||
{
|
||||
header: t`Destination`,
|
||||
value: webhookCall.url,
|
||||
},
|
||||
];
|
||||
}, [webhookCall]);
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<SheetContent position="right" size="lg" className="max-w-2xl overflow-y-auto">
|
||||
<SheetTitle>
|
||||
<h2 className="text-lg font-semibold">
|
||||
<Trans>Webhook Details</Trans>
|
||||
</h2>
|
||||
<p className="text-muted-foreground font-mono text-xs">{webhookCall.id}</p>
|
||||
</SheetTitle>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mt-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<h4 className="text-muted-foreground mb-3 text-xs font-semibold uppercase tracking-wider">
|
||||
<Trans>Details</Trans>
|
||||
</h4>
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
resendWebhookCall({
|
||||
webhookId: webhookCall.webhookId,
|
||||
webhookCallId: webhookCall.id,
|
||||
})
|
||||
}
|
||||
tabIndex={-1}
|
||||
loading={isResending}
|
||||
size="sm"
|
||||
className="mb-2"
|
||||
>
|
||||
{!isResending && <RotateCwIcon className="mr-2 h-3.5 w-3.5" />}
|
||||
<Trans>Resend</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border-border overflow-hidden rounded-lg border">
|
||||
<table className="w-full text-left text-sm">
|
||||
<tbody className="divide-border bg-muted/30 divide-y">
|
||||
{generalWebhookDetails.map(({ header, value }, index) => (
|
||||
<tr key={index}>
|
||||
<td className="text-muted-foreground border-border w-1/3 border-r px-4 py-2 font-mono text-xs">
|
||||
{header}
|
||||
</td>
|
||||
<td className="text-foreground break-all px-4 py-2 font-mono text-xs">
|
||||
{value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payload Tabs */}
|
||||
<div className="py-6">
|
||||
<div className="border-border mb-4 flex items-center gap-4 border-b">
|
||||
<button
|
||||
onClick={() => setActiveTab('request')}
|
||||
className={cn(
|
||||
'relative pb-2 text-sm font-medium transition-colors',
|
||||
activeTab === 'request'
|
||||
? 'text-foreground after:bg-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Trans>Request</Trans>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('response')}
|
||||
className={cn(
|
||||
'relative pb-2 text-sm font-medium transition-colors',
|
||||
activeTab === 'response'
|
||||
? 'text-foreground after:bg-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Trans>Response</Trans>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="group relative">
|
||||
<div className="absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<CopyTextButton
|
||||
value={JSON.stringify(
|
||||
activeTab === 'request' ? webhookCall.requestBody : webhookCall.responseBody,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
||||
/>
|
||||
</div>
|
||||
<pre className="bg-muted/50 border-border text-foreground overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed">
|
||||
{JSON.stringify(
|
||||
activeTab === 'request' ? webhookCall.requestBody : webhookCall.responseBody,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{activeTab === 'response' && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-muted-foreground mb-3 text-xs font-semibold uppercase tracking-wider">
|
||||
<Trans>Response Headers</Trans>
|
||||
</h4>
|
||||
<div className="border-border overflow-hidden rounded-lg border">
|
||||
<table className="w-full text-left text-sm">
|
||||
<tbody className="divide-border bg-muted/30 divide-y">
|
||||
{Object.entries(webhookCall.responseHeaders as Record<string, string>).map(
|
||||
([key, value]) => (
|
||||
<tr key={key}>
|
||||
<td className="text-muted-foreground border-border w-1/3 border-r px-4 py-2 font-mono text-xs">
|
||||
{key}
|
||||
</td>
|
||||
<td className="text-foreground break-all px-4 py-2 font-mono text-xs">
|
||||
{value as string}
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -51,7 +51,7 @@ export const AdminDashboardUsersTable = ({
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: 'ID',
|
||||
header: _(msg`ID`),
|
||||
accessorKey: 'id',
|
||||
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: 'ID',
|
||||
header: _(msg`ID`),
|
||||
accessorKey: 'id',
|
||||
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||
},
|
||||
|
||||
@@ -113,7 +113,11 @@ export const AdminOrganisationsTable = ({
|
||||
isPaid ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{isPaid ? t`Paid` : t`Free`}
|
||||
{isPaid ? (
|
||||
<Trans context="Subscription status">Paid</Trans>
|
||||
) : (
|
||||
<Trans context="Subscription status">Free</Trans>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -131,7 +135,7 @@ export const AdminOrganisationsTable = ({
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
) : (
|
||||
'None'
|
||||
<Trans>None</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -96,11 +96,11 @@ export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
|
||||
cell: ({ row }) => <span>{formatDocumentAuditLogAction(_, row.original).description}</span>,
|
||||
},
|
||||
{
|
||||
header: 'IP Address',
|
||||
header: _(msg`IP Address`),
|
||||
accessorKey: 'ipAddress',
|
||||
},
|
||||
{
|
||||
header: 'Browser',
|
||||
header: _(msg`Browser`),
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.userAgent) {
|
||||
return 'N/A';
|
||||
|
||||
@@ -104,7 +104,7 @@ export const SettingsSecurityActivityTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'IP Address',
|
||||
header: _(msg`IP Address`),
|
||||
accessorKey: 'ipAddress',
|
||||
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
|
||||
},
|
||||
|
||||
@@ -28,19 +28,19 @@ export const UserBillingOrganisationsTable = () => {
|
||||
const getSubscriptionStatusDisplay = (status: SubscriptionStatus | undefined) => {
|
||||
return match(status)
|
||||
.with(SubscriptionStatus.ACTIVE, () => ({
|
||||
label: t`Active`,
|
||||
label: t({ message: `Active`, context: `Subscription status` }),
|
||||
variant: 'default' as const,
|
||||
}))
|
||||
.with(SubscriptionStatus.PAST_DUE, () => ({
|
||||
label: t`Past Due`,
|
||||
label: t({ message: `Past Due`, context: `Subscription status` }),
|
||||
variant: 'warning' as const,
|
||||
}))
|
||||
.with(SubscriptionStatus.INACTIVE, () => ({
|
||||
label: t`Inactive`,
|
||||
label: t({ message: `Inactive`, context: `Subscription status` }),
|
||||
variant: 'neutral' as const,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
label: t`Free`,
|
||||
label: t({ message: `Free`, context: `Subscription status` }),
|
||||
variant: 'neutral' as const,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ import { HydratedRouter } from 'react-router/dom';
|
||||
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
|
||||
import './utils/polyfills/promise-with-resolvers';
|
||||
|
||||
function PosthogInit() {
|
||||
const postHogConfig = extractPostHogConfig();
|
||||
|
||||
|
||||
+1
-15
@@ -1,6 +1,3 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import Plausible from 'plausible-tracker';
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
@@ -17,7 +14,7 @@ import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes'
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||
import { createPublicEnv, env } from '@documenso/lib/utils/env';
|
||||
import { createPublicEnv } from '@documenso/lib/utils/env';
|
||||
import { extractLocaleData } from '@documenso/lib/utils/i18n';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { getOrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session';
|
||||
@@ -31,11 +28,6 @@ import { langCookie } from './storage/lang-cookie.server';
|
||||
import { themeSessionResolver } from './storage/theme-session.server';
|
||||
import { appMetaTags } from './utils/meta';
|
||||
|
||||
const { trackPageview } = Plausible({
|
||||
domain: 'documenso.com',
|
||||
trackLocalhost: false,
|
||||
});
|
||||
|
||||
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
|
||||
|
||||
export function meta() {
|
||||
@@ -92,12 +84,6 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (env('NODE_ENV') === 'production') {
|
||||
trackPageview();
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
|
||||
<LayoutContent>{children}</LayoutContent>
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function AdminDocumentsPage() {
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Last updated',
|
||||
header: _(msg`Last updated`),
|
||||
accessorKey: 'updatedAt',
|
||||
cell: ({ row }) => i18n.date(row.original.updatedAt),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { getOrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
|
||||
import type { DateRange } from '@documenso/lib/types/search-params';
|
||||
import { getAdminOrganisation } from '@documenso/trpc/server/admin-router/get-admin-organisation';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { OrganisationInsightsTable } from '~/components/tables/organisation-insights-table';
|
||||
|
||||
@@ -38,12 +42,17 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
export default function OrganisationInsights({ loaderData }: Route.ComponentProps) {
|
||||
const { insights, page, perPage, dateRange, view, organisationName } = loaderData;
|
||||
const { insights, page, perPage, dateRange, view, organisationName, organisationId } = loaderData;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-4xl font-semibold">{organisationName}</h2>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/admin/organisations/${organisationId}`}>
|
||||
<Trans>Manage organisation</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<OrganisationInsightsTable
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
const typedOrganisations: OrganisationOverview[] = organisations.map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.name || '',
|
||||
signingVolume: item.signingVolume,
|
||||
signingVolume: item.signingVolume || 0,
|
||||
createdAt: item.createdAt || new Date(),
|
||||
customerId: item.customerId || '',
|
||||
subscriptionStatus: item.subscriptionStatus,
|
||||
|
||||
@@ -162,7 +162,13 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
||||
<SettingsHeader
|
||||
title={t`Manage organisation`}
|
||||
subtitle={t`Manage the ${organisation.name} organisation`}
|
||||
/>
|
||||
>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/admin/organisation-insights/${organisationId}`}>
|
||||
<Trans>View insights</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsHeader>
|
||||
|
||||
<GenericOrganisationAdminForm organisation={organisation} />
|
||||
|
||||
|
||||
@@ -152,12 +152,6 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
||||
<div className="mt-5 grid grid-cols-2 gap-8">
|
||||
<MonthlyActiveUsersChart title={_(msg`MAU (signed in)`)} data={monthlyActiveUsers} />
|
||||
|
||||
<MonthlyActiveUsersChart
|
||||
title={_(msg`Cumulative MAU (signed in)`)}
|
||||
data={monthlyActiveUsers}
|
||||
cummulative
|
||||
/>
|
||||
|
||||
<AdminStatsUsersWithDocumentsChart
|
||||
data={monthlyUsersWithDocuments}
|
||||
title={_(msg`MAU (created document)`)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user