mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
25 Commits
feat/updat
...
eff7d90f43
| Author | SHA1 | Date | |
|---|---|---|---|
| eff7d90f43 | |||
| db5524f8ce | |||
| 3d539b20ad | |||
| 48626b9169 | |||
| 88371b665a | |||
| 1650c55b19 | |||
| 60d73e0921 | |||
| 4a779ec81e | |||
| 7f19ec1265 | |||
| d6a2f5a4c9 | |||
| d05bfa9fed | |||
| d2a009d52e | |||
| 9350c53c7d | |||
| ffce7a2c81 | |||
| 353bdce86b | |||
| e13b9f7c84 | |||
| 9908580bf1 | |||
| b0b07106b4 | |||
| 35250fa308 | |||
| 5cdd7f8623 | |||
| 47bdcd833f | |||
| 03eb6af69a | |||
| 88836404d1 | |||
| 2eebc0e439 | |||
| 4a3859ec60 |
@ -13,6 +13,10 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
|||||||
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
|
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||||
|
# Find documentation on setting up Microsoft OAuth here:
|
||||||
|
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#microsoft-oauth-azure-ad
|
||||||
|
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=""
|
||||||
|
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=""
|
||||||
|
|
||||||
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
||||||
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
||||||
@ -25,6 +29,10 @@ NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
|||||||
# URL used by the web app to request itself (e.g. local background jobs)
|
# URL used by the web app to request itself (e.g. local background jobs)
|
||||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
|
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# [[SERVER]]
|
||||||
|
# OPTIONAL: The port the server will listen on. Defaults to 3000.
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
# [[DATABASE]]
|
# [[DATABASE]]
|
||||||
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||||
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
||||||
|
|||||||
692
CODE_STYLE.md
Normal file
692
CODE_STYLE.md
Normal file
@ -0,0 +1,692 @@
|
|||||||
|
# Documenso Code Style Guide
|
||||||
|
|
||||||
|
This document captures the code style, patterns, and conventions used in the Documenso codebase. It covers both enforceable rules and subjective "taste" elements that make our code consistent and maintainable.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [General Principles](#general-principles)
|
||||||
|
2. [TypeScript Conventions](#typescript-conventions)
|
||||||
|
3. [Imports & Dependencies](#imports--dependencies)
|
||||||
|
4. [Functions & Methods](#functions--methods)
|
||||||
|
5. [React & Components](#react--components)
|
||||||
|
6. [Error Handling](#error-handling)
|
||||||
|
7. [Async/Await Patterns](#asyncawait-patterns)
|
||||||
|
8. [Whitespace & Formatting](#whitespace--formatting)
|
||||||
|
9. [Naming Conventions](#naming-conventions)
|
||||||
|
10. [Pattern Matching](#pattern-matching)
|
||||||
|
11. [Database & Prisma](#database--prisma)
|
||||||
|
12. [TRPC Patterns](#trpc-patterns)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## General Principles
|
||||||
|
|
||||||
|
- **Functional over Object-Oriented**: Prefer functional programming patterns over classes
|
||||||
|
- **Explicit over Implicit**: Be explicit about types, return values, and error cases
|
||||||
|
- **Early Returns**: Use guard clauses and early returns to reduce nesting
|
||||||
|
- **Immutability**: Favor `const` over `let`; avoid mutation where possible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TypeScript Conventions
|
||||||
|
|
||||||
|
### Type Definitions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Prefer `type` over `interface`
|
||||||
|
type CreateDocumentOptions = {
|
||||||
|
templateId: number;
|
||||||
|
userId: number;
|
||||||
|
recipients: Recipient[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ❌ Avoid interfaces unless absolutely necessary
|
||||||
|
interface CreateDocumentOptions {
|
||||||
|
templateId: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Imports
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Use `type` keyword for type-only imports
|
||||||
|
import type { Document, Recipient } from '@prisma/client';
|
||||||
|
import { DocumentStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
// Types in function signatures
|
||||||
|
export const findDocuments = async ({ userId, teamId }: FindDocumentsOptions) => {
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline Types for Function Parameters
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Extract inline types to named types
|
||||||
|
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
|
||||||
|
templateRecipientId: number;
|
||||||
|
fields: Field[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalRecipients: FinalRecipient[] = [];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Imports & Dependencies
|
||||||
|
|
||||||
|
### Import Organization
|
||||||
|
|
||||||
|
Imports should be organized in the following order with blank lines between groups:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. React imports
|
||||||
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
// 2. Third-party library imports (alphabetically)
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { Document, Recipient } from '@prisma/client';
|
||||||
|
import { DocumentStatus, RecipientRole } from '@prisma/client';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
// 3. Internal package imports (from @documenso/*)
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
// 4. Relative imports
|
||||||
|
import { getTeamById } from '../team/get-team';
|
||||||
|
import type { FindResultResponse } from './types';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Destructuring Imports
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Destructure specific exports
|
||||||
|
// ✅ Use type imports for types
|
||||||
|
import type { Document } from '@prisma/client';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Functions & Methods
|
||||||
|
|
||||||
|
### Arrow Functions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Always use arrow functions for functions
|
||||||
|
export const createDocument = async ({
|
||||||
|
userId,
|
||||||
|
title,
|
||||||
|
}: CreateDocumentOptions) => {
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Callbacks and handlers
|
||||||
|
const onSubmit = useCallback(async () => {
|
||||||
|
// ...
|
||||||
|
}, [dependencies]);
|
||||||
|
|
||||||
|
// ❌ Avoid regular function declarations
|
||||||
|
function createDocument() {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Function Parameters
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Use destructured object parameters for multiple params
|
||||||
|
export const findDocuments = async ({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
status = ExtendedDocumentStatus.ALL,
|
||||||
|
page = 1,
|
||||||
|
perPage = 10,
|
||||||
|
}: FindDocumentsOptions) => {
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Destructure on separate line when needed
|
||||||
|
const onFormSubmit = form.handleSubmit(onSubmit);
|
||||||
|
|
||||||
|
// ✅ Deconstruct nested properties explicitly
|
||||||
|
const { user } = ctx;
|
||||||
|
const { templateId } = input;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## React & Components
|
||||||
|
|
||||||
|
### Component Definition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Use const with arrow function
|
||||||
|
export const AddSignersFormPartial = ({
|
||||||
|
documentFlow,
|
||||||
|
recipients,
|
||||||
|
fields,
|
||||||
|
onSubmit,
|
||||||
|
}: AddSignersFormProps) => {
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
|
||||||
|
// ❌ Never use classes
|
||||||
|
class MyComponent extends React.Component {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hooks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Group related hooks together with blank line separation
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { currentStep, totalSteps, previousStep } = useStep();
|
||||||
|
|
||||||
|
const form = useForm<TFormSchema>({
|
||||||
|
resolver: zodResolver(ZFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Handlers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Use arrow functions with descriptive names
|
||||||
|
const onFormSubmit = async () => {
|
||||||
|
await form.trigger();
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFieldCopy = useCallback(
|
||||||
|
(event?: KeyboardEvent | null) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
[dependencies],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ✅ Inline handlers for simple operations
|
||||||
|
<Button onClick={() => setOpen(false)}>Close</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Descriptive state names with auxiliary verbs
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||||
|
|
||||||
|
// ✅ Complex state in single useState when related
|
||||||
|
const [coords, setCoords] = useState({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Try-Catch Blocks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Use try-catch for operations that might fail
|
||||||
|
try {
|
||||||
|
const document = await getDocumentById({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: document,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Throwing Errors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Use AppError for application errors
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Template not found',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Use descriptive error messages
|
||||||
|
if (!template) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: `Template with ID ${templateId} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Parsing on Frontend
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Parse errors on the frontend
|
||||||
|
try {
|
||||||
|
await updateOrganisation({ organisationId, data });
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`An error occurred`,
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Async/Await Patterns
|
||||||
|
|
||||||
|
### Async Function Definitions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Mark async functions clearly
|
||||||
|
export const createDocument = async ({
|
||||||
|
userId,
|
||||||
|
title,
|
||||||
|
}: Options): Promise<Document> => {
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Use await for promises
|
||||||
|
const document = await prisma.document.create({ data });
|
||||||
|
|
||||||
|
// ✅ Use Promise.all for parallel operations
|
||||||
|
const [document, recipients] = await Promise.all([
|
||||||
|
getDocumentById({ documentId }),
|
||||||
|
getRecipientsForDocument({ documentId }),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Void for Fire-and-Forget
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Use void for intentionally unwaited promises
|
||||||
|
void handleAutoSave();
|
||||||
|
|
||||||
|
// ✅ Or in event handlers
|
||||||
|
onClick={() => void onFormSubmit()}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Whitespace & Formatting
|
||||||
|
|
||||||
|
### Blank Lines Between Concepts
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Blank line after imports
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export const findDocuments = async () => {
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Blank line between logical sections
|
||||||
|
const user = await prisma.user.findFirst({ where: { id: userId } });
|
||||||
|
|
||||||
|
let team = null;
|
||||||
|
|
||||||
|
if (teamId !== undefined) {
|
||||||
|
team = await getTeamById({ userId, teamId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Blank line before return statements
|
||||||
|
const result = await someOperation();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Function/Method Spacing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ No blank lines between chained methods in same operation
|
||||||
|
const documents = await prisma.document
|
||||||
|
.findMany({ where: { userId } })
|
||||||
|
.then((docs) => docs.map(maskTokens));
|
||||||
|
|
||||||
|
// ✅ Blank line between different operations
|
||||||
|
const document = await createDocument({ userId });
|
||||||
|
|
||||||
|
await sendDocument({ documentId: document.id });
|
||||||
|
|
||||||
|
return document;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Object and Array Formatting
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Multi-line when complex
|
||||||
|
const options = {
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
status: ExtendedDocumentStatus.ALL,
|
||||||
|
page: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Single line when simple
|
||||||
|
const coords = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
// ✅ Array items on separate lines when objects
|
||||||
|
const recipients = [
|
||||||
|
{
|
||||||
|
name: 'John',
|
||||||
|
email: 'john@example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Jane',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
### Variables
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ camelCase for variables and functions
|
||||||
|
const documentId = 123;
|
||||||
|
const onSubmit = () => {};
|
||||||
|
|
||||||
|
// ✅ Descriptive names with auxiliary verbs for booleans
|
||||||
|
const isLoading = false;
|
||||||
|
const hasError = false;
|
||||||
|
const canEdit = true;
|
||||||
|
const shouldRender = true;
|
||||||
|
|
||||||
|
// ✅ Prefix with $ for DOM elements
|
||||||
|
const $page = document.querySelector('.page');
|
||||||
|
const $inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types and Schemas
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ PascalCase for types
|
||||||
|
type CreateDocumentOptions = {
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Prefix Zod schemas with Z
|
||||||
|
const ZCreateDocumentSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Prefix type from Zod schema with T
|
||||||
|
type TCreateDocumentSchema = z.infer<typeof ZCreateDocumentSchema>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Constants
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ UPPER_SNAKE_CASE for true constants
|
||||||
|
const DEFAULT_DOCUMENT_DATE_FORMAT = 'dd/MM/yyyy';
|
||||||
|
const MAX_FILE_SIZE = 1024 * 1024 * 5;
|
||||||
|
|
||||||
|
// ✅ camelCase for const variables that aren't "constants"
|
||||||
|
const userId = await getUserId();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Verb-based names for functions
|
||||||
|
const createDocument = async () => {};
|
||||||
|
const findDocuments = async () => {};
|
||||||
|
const updateDocument = async () => {};
|
||||||
|
const deleteDocument = async () => {};
|
||||||
|
|
||||||
|
// ✅ On prefix for event handlers
|
||||||
|
const onSubmit = () => {};
|
||||||
|
const onClick = () => {};
|
||||||
|
const onFieldCopy = () => {}; // 'on' is also acceptable
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clarity Over Brevity
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Prefer descriptive names over abbreviations
|
||||||
|
const superLongMethodThatIsCorrect = () => {};
|
||||||
|
const recipientAuthenticationOptions = {};
|
||||||
|
const documentMetadata = {};
|
||||||
|
|
||||||
|
// ❌ Avoid abbreviations that sacrifice clarity
|
||||||
|
const supLongMethThatIsCorrect = () => {};
|
||||||
|
const recipAuthOpts = {};
|
||||||
|
const docMeta = {};
|
||||||
|
|
||||||
|
// ✅ Common abbreviations that are widely understood are acceptable
|
||||||
|
const userId = 123;
|
||||||
|
const htmlElement = document.querySelector('div');
|
||||||
|
const apiResponse = await fetch('/api');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern Matching
|
||||||
|
|
||||||
|
### Using ts-pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
// ✅ Use match for complex conditionals
|
||||||
|
const result = match(status)
|
||||||
|
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||||
|
status: 'draft',
|
||||||
|
}))
|
||||||
|
.with(ExtendedDocumentStatus.PENDING, () => ({
|
||||||
|
status: 'pending',
|
||||||
|
}))
|
||||||
|
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
||||||
|
status: 'completed',
|
||||||
|
}))
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
// ✅ Use .otherwise() for default case when not exhaustive
|
||||||
|
const value = match(type)
|
||||||
|
.with('text', () => 'Text field')
|
||||||
|
.with('number', () => 'Number field')
|
||||||
|
.otherwise(() => 'Unknown field');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database & Prisma
|
||||||
|
|
||||||
|
### Query Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Destructure commonly used fields
|
||||||
|
const { id, email, name } = user;
|
||||||
|
|
||||||
|
// ✅ Use select to limit returned fields
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Use include for relations
|
||||||
|
const document = await prisma.document.findFirst({
|
||||||
|
where: { id: documentId },
|
||||||
|
include: {
|
||||||
|
recipients: true,
|
||||||
|
fields: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transactions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Use transactions for related operations
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const document = await tx.document.create({ data });
|
||||||
|
|
||||||
|
await tx.field.createMany({ data: fieldsData });
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({ data: auditData });
|
||||||
|
|
||||||
|
return document;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Where Clauses
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Build complex where clauses separately
|
||||||
|
const whereClause: Prisma.DocumentWhereInput = {
|
||||||
|
AND: [
|
||||||
|
{ userId: user.id },
|
||||||
|
{ deletedAt: null },
|
||||||
|
{ status: { in: [DocumentStatus.DRAFT, DocumentStatus.PENDING] } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const documents = await prisma.document.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TRPC Patterns
|
||||||
|
|
||||||
|
### Router Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Destructure context and input at start
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const { teamId } = ctx;
|
||||||
|
const { templateId } = input;
|
||||||
|
|
||||||
|
ctx.logger.info({
|
||||||
|
input: { templateId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return await getTemplateById({
|
||||||
|
id: templateId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request/Response Schemas
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Name schemas clearly
|
||||||
|
const ZCreateDocumentRequestSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
recipients: z.array(ZRecipientSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZCreateDocumentResponseSchema = z.object({
|
||||||
|
documentId: z.number(),
|
||||||
|
status: z.string(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling in TRPC
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Catch and transform errors appropriately
|
||||||
|
try {
|
||||||
|
const result = await createDocument({ userId, data });
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
return AppError.toRestAPIError(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Or throw AppError directly
|
||||||
|
if (!template) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Template not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Patterns
|
||||||
|
|
||||||
|
### Optional Chaining
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Use optional chaining for potentially undefined values
|
||||||
|
const email = user?.email;
|
||||||
|
const recipientToken = recipient?.token ?? '';
|
||||||
|
|
||||||
|
// ✅ Use nullish coalescing for defaults
|
||||||
|
const pageSize = perPage ?? 10;
|
||||||
|
const status = documentStatus ?? DocumentStatus.DRAFT;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Array Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Use functional array methods
|
||||||
|
const activeRecipients = recipients.filter((r) => r.signingStatus === 'SIGNED');
|
||||||
|
const recipientEmails = recipients.map((r) => r.email);
|
||||||
|
const hasSignedRecipients = recipients.some((r) => r.signingStatus === 'SIGNED');
|
||||||
|
|
||||||
|
// ✅ Use find instead of filter + [0]
|
||||||
|
const recipient = recipients.find((r) => r.id === recipientId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Rendering
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Use && for conditional rendering
|
||||||
|
{isLoading && <Loader />}
|
||||||
|
|
||||||
|
// ✅ Use ternary for either/or
|
||||||
|
{isLoading ? <Loader /> : <Content />}
|
||||||
|
|
||||||
|
// ✅ Extract complex conditions to variables
|
||||||
|
const shouldShowAdvanced = isAdmin && hasPermission && !isDisabled;
|
||||||
|
{shouldShowAdvanced && <AdvancedSettings />}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When in Doubt
|
||||||
|
|
||||||
|
- **Consistency**: Follow the patterns you see in similar files
|
||||||
|
- **Readability**: Favor code that's easy to read over clever one-liners
|
||||||
|
- **Explicitness**: Be explicit rather than implicit
|
||||||
|
- **Whitespace**: Use blank lines to separate logical sections
|
||||||
|
- **Early Returns**: Use guard clauses to reduce nesting
|
||||||
|
- **Functional**: Prefer functional patterns over imperative ones
|
||||||
@ -27,3 +27,33 @@ NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=<your-client-secret>
|
|||||||
```
|
```
|
||||||
|
|
||||||
Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile.
|
Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile.
|
||||||
|
|
||||||
|
## Microsoft OAuth (Azure AD)
|
||||||
|
|
||||||
|
To use Microsoft OAuth, you will need to create an Azure AD application registration in the Microsoft Azure portal. This will allow users to sign in with their Microsoft accounts.
|
||||||
|
|
||||||
|
### Create and configure a new Azure AD application
|
||||||
|
|
||||||
|
1. Go to the [Azure Portal](https://portal.azure.com/)
|
||||||
|
2. Navigate to **Azure Active Directory** (or **Microsoft Entra ID** in newer Azure portals)
|
||||||
|
3. In the left sidebar, click **App registrations**
|
||||||
|
4. Click **New registration**
|
||||||
|
5. Enter a name for your application (e.g., "Documenso")
|
||||||
|
6. Under **Supported account types**, select **Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)** to allow any Microsoft account to sign in
|
||||||
|
7. Under **Redirect URI**, select **Web** and enter: `https://<documenso-domain>/api/auth/callback/microsoft`
|
||||||
|
8. Click **Register**
|
||||||
|
|
||||||
|
### Configure the application
|
||||||
|
|
||||||
|
1. After registration, you'll be taken to the app's overview page
|
||||||
|
2. Copy the **Application (client) ID** - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_ID`
|
||||||
|
3. In the left sidebar, click **Certificates & secrets**
|
||||||
|
4. Under **Client secrets**, click **New client secret**
|
||||||
|
5. Add a description and select an expiration period
|
||||||
|
6. Click **Add** and copy the **Value** (not the Secret ID) - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET`
|
||||||
|
7. In the Documenso environment variables, set the following:
|
||||||
|
|
||||||
|
```
|
||||||
|
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=<your-application-client-id>
|
||||||
|
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=<your-client-secret-value>
|
||||||
|
```
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3003",
|
"dev": "next dev -p 3003",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start -p 3003",
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "next lint --fix",
|
||||||
"clean": "rimraf .next && rimraf node_modules"
|
"clean": "rimraf .next && rimraf node_modules"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -27,9 +27,45 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Noto Sans';
|
||||||
|
src: url('/fonts/noto-sans.ttf') format('truetype-variations');
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Korean noto sans */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Noto Sans Korean';
|
||||||
|
src: url('/fonts/noto-sans-korean.ttf') format('truetype-variations');
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Japanese noto sans */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Noto Sans Japanese';
|
||||||
|
src: url('/fonts/noto-sans-japanese.ttf') format('truetype-variations');
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chinese noto sans */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Noto Sans Chinese';
|
||||||
|
src: url('/fonts/noto-sans-chinese.ttf') format('truetype-variations');
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--font-sans: 'Inter';
|
--font-sans: 'Inter';
|
||||||
--font-signature: 'Caveat';
|
--font-signature: 'Caveat';
|
||||||
|
--font-noto: 'Noto Sans', 'Noto Sans Korean', 'Noto Sans Japanese', 'Noto Sans Chinese';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,218 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { OrganisationMemberRole } from '@prisma/client';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type AdminOrganisationMemberUpdateDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
organisationId: string;
|
||||||
|
organisationMember: TGetAdminOrganisationResponse['members'][number];
|
||||||
|
isOwner: boolean;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZUpdateOrganisationMemberFormSchema = z.object({
|
||||||
|
role: z.enum(['OWNER', 'ADMIN', 'MANAGER', 'MEMBER']),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ZUpdateOrganisationMemberSchema = z.infer<typeof ZUpdateOrganisationMemberFormSchema>;
|
||||||
|
|
||||||
|
export const AdminOrganisationMemberUpdateDialog = ({
|
||||||
|
trigger,
|
||||||
|
organisationId,
|
||||||
|
organisationMember,
|
||||||
|
isOwner,
|
||||||
|
...props
|
||||||
|
}: AdminOrganisationMemberUpdateDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Determine the current role value for the form
|
||||||
|
const currentRoleValue = isOwner
|
||||||
|
? 'OWNER'
|
||||||
|
: getHighestOrganisationRoleInGroup(
|
||||||
|
organisationMember.organisationGroupMembers.map((ogm) => ogm.group),
|
||||||
|
);
|
||||||
|
const organisationMemberName = organisationMember.user.name ?? organisationMember.user.email;
|
||||||
|
|
||||||
|
const form = useForm<ZUpdateOrganisationMemberSchema>({
|
||||||
|
resolver: zodResolver(ZUpdateOrganisationMemberFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
role: currentRoleValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: updateOrganisationMemberRole } =
|
||||||
|
trpc.admin.organisationMember.updateRole.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => {
|
||||||
|
try {
|
||||||
|
await updateOrganisationMemberRole({
|
||||||
|
organisationId,
|
||||||
|
userId: organisationMember.userId,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
|
||||||
|
const roleLabel = match(role)
|
||||||
|
.with('OWNER', () => t`Owner`)
|
||||||
|
.with(OrganisationMemberRole.ADMIN, () => t`Admin`)
|
||||||
|
.with(OrganisationMemberRole.MANAGER, () => t`Manager`)
|
||||||
|
.with(OrganisationMemberRole.MEMBER, () => t`Member`)
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Success`,
|
||||||
|
description:
|
||||||
|
role === 'OWNER'
|
||||||
|
? t`Ownership transferred to ${organisationMemberName}.`
|
||||||
|
: t`Updated ${organisationMemberName} to ${roleLabel}.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
// Refresh the page to show updated data
|
||||||
|
await navigate(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`An unknown error occurred`,
|
||||||
|
description: t`We encountered an unknown error while attempting to update this organisation member. Please try again later.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
role: currentRoleValue,
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, currentRoleValue, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="secondary">
|
||||||
|
<Trans>Update role</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Update organisation member</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>
|
||||||
|
You are currently updating{' '}
|
||||||
|
<span className="font-bold">{organisationMemberName}.</span>
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Role</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent className="w-full" position="popper">
|
||||||
|
<SelectItem value="OWNER">
|
||||||
|
<Trans>Owner</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={OrganisationMemberRole.ADMIN}>
|
||||||
|
<Trans>Admin</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={OrganisationMemberRole.MANAGER}>
|
||||||
|
<Trans>Manager</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={OrganisationMemberRole.MEMBER}>
|
||||||
|
<Trans>Member</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
<Trans>Update</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -19,13 +19,15 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
type DocumentDuplicateDialogProps = {
|
type DocumentDuplicateDialogProps = {
|
||||||
id: number;
|
id: string;
|
||||||
|
token?: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentDuplicateDialog = ({
|
export const DocumentDuplicateDialog = ({
|
||||||
id,
|
id,
|
||||||
|
token,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DocumentDuplicateDialogProps) => {
|
}: DocumentDuplicateDialogProps) => {
|
||||||
@ -36,27 +38,23 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { data: document, isLoading } = trpcReact.document.get.useQuery(
|
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||||
{
|
trpcReact.envelope.item.getManyByToken.useQuery(
|
||||||
documentId: id,
|
{
|
||||||
},
|
envelopeId: id,
|
||||||
{
|
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||||
queryHash: `document-duplicate-dialog-${id}`,
|
},
|
||||||
enabled: open === true,
|
{
|
||||||
},
|
enabled: open,
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const documentData = document?.documentData
|
const envelopeItems = envelopeItemsPayload?.data || [];
|
||||||
? {
|
|
||||||
...document.documentData,
|
|
||||||
data: document.documentData.initialData,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||||
trpcReact.document.duplicate.useMutation({
|
trpcReact.envelope.duplicate.useMutation({
|
||||||
onSuccess: async ({ id }) => {
|
onSuccess: async ({ id }) => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document Duplicated`),
|
title: _(msg`Document Duplicated`),
|
||||||
@ -71,7 +69,7 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
const onDuplicate = async () => {
|
const onDuplicate = async () => {
|
||||||
try {
|
try {
|
||||||
await duplicateDocument({ documentId: id });
|
await duplicateEnvelope({ envelopeId: id });
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
@ -83,14 +81,14 @@ export const DocumentDuplicateDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Duplicate</Trans>
|
<Trans>Duplicate</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{!documentData || isLoading ? (
|
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
<Trans>Loading Document...</Trans>
|
<Trans>Loading Document...</Trans>
|
||||||
@ -98,7 +96,12 @@ export const DocumentDuplicateDialog = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||||
<PDFViewer key={document?.id} documentData={documentData} />
|
<PDFViewer
|
||||||
|
key={envelopeItems[0].id}
|
||||||
|
envelopeItem={envelopeItems[0]}
|
||||||
|
token={undefined}
|
||||||
|
version="original"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -115,8 +118,8 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isDuplicateLoading || isLoading}
|
disabled={isDuplicating}
|
||||||
loading={isDuplicateLoading}
|
loading={isDuplicating}
|
||||||
onClick={onDuplicate}
|
onClick={onDuplicate}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -15,17 +15,16 @@ import {
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { InfoIcon } from 'lucide-react';
|
import { InfoIcon } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
|
||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
|
||||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
|
||||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -61,8 +60,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
export type EnvelopeDistributeDialogProps = {
|
export type EnvelopeDistributeDialogProps = {
|
||||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Pick<Field, 'type' | 'recipientId'>[];
|
||||||
};
|
};
|
||||||
|
onDistribute?: () => Promise<void>;
|
||||||
|
documentRootPath: string;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -84,13 +85,19 @@ export const ZEnvelopeDistributeFormSchema = z.object({
|
|||||||
|
|
||||||
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
|
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
|
||||||
|
|
||||||
export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistributeDialogProps) => {
|
export const EnvelopeDistributeDialog = ({
|
||||||
|
envelope,
|
||||||
|
trigger,
|
||||||
|
documentRootPath,
|
||||||
|
onDistribute,
|
||||||
|
}: EnvelopeDistributeDialogProps) => {
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const recipients = envelope.recipients;
|
const recipients = envelope.recipients;
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
@ -127,22 +134,44 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
|
|
||||||
const distributionMethod = watch('meta.distributionMethod');
|
const distributionMethod = watch('meta.distributionMethod');
|
||||||
|
|
||||||
const everySignerHasSignature = useMemo(
|
const recipientsMissingSignatureFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
envelope.recipients
|
envelope.recipients.filter(
|
||||||
.filter((recipient) => recipient.role === RecipientRole.SIGNER)
|
(recipient) =>
|
||||||
.every((recipient) =>
|
recipient.role === RecipientRole.SIGNER &&
|
||||||
envelope.fields.some(
|
!envelope.fields.some(
|
||||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
[envelope.recipients, envelope.fields],
|
[envelope.recipients, envelope.fields],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const invalidEnvelopeCode = useMemo(() => {
|
||||||
|
if (recipientsMissingSignatureFields.length > 0) {
|
||||||
|
return 'MISSING_SIGNATURES';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.recipients.length === 0) {
|
||||||
|
return 'MISSING_RECIPIENTS';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
|
||||||
|
|
||||||
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
|
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await distributeEnvelope({ envelopeId: envelope.id, meta });
|
await distributeEnvelope({ envelopeId: envelope.id, meta });
|
||||||
|
|
||||||
|
await onDistribute?.();
|
||||||
|
|
||||||
|
let redirectPath = `${documentRootPath}/${envelope.id}`;
|
||||||
|
|
||||||
|
if (meta.distributionMethod === DocumentDistributionMethod.NONE) {
|
||||||
|
redirectPath += '?action=copy-links';
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigate(redirectPath);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Envelope distributed`,
|
title: t`Envelope distributed`,
|
||||||
description: t`Your envelope has been distributed successfully.`,
|
description: t`Your envelope has been distributed successfully.`,
|
||||||
@ -178,7 +207,8 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
<Trans>Recipients will be able to sign the document once sent</Trans>
|
<Trans>Recipients will be able to sign the document once sent</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{everySignerHasSignature ? (
|
|
||||||
|
{!invalidEnvelopeCode ? (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
<fieldset disabled={isSubmitting}>
|
<fieldset disabled={isSubmitting}>
|
||||||
@ -200,7 +230,11 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="min-h-72">
|
<div
|
||||||
|
className={cn('min-h-72', {
|
||||||
|
'min-h-[23rem]': organisation.organisationClaim.flags.emailDomains,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<AnimatePresence initial={false} mode="wait">
|
<AnimatePresence initial={false} mode="wait">
|
||||||
{distributionMethod === DocumentDistributionMethod.EMAIL && (
|
{distributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -335,71 +369,18 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||||
className="min-h-60 rounded-lg border"
|
className="min-h-60 rounded-lg border"
|
||||||
>
|
>
|
||||||
{envelope.status === DocumentStatus.DRAFT ? (
|
<div className="text-muted-foreground py-24 text-center text-sm">
|
||||||
<div className="text-muted-foreground py-24 text-center text-sm">
|
<p>
|
||||||
<p>
|
<Trans>We won't send anything to notify recipients.</Trans>
|
||||||
<Trans>We won't send anything to notify recipients.</Trans>
|
</p>
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
<Trans>
|
<Trans>
|
||||||
We will generate signing links for you, which you can send to the
|
We will generate signing links for you, which you can send to the
|
||||||
recipients through your method of choice.
|
recipients through your method of choice.
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<ul className="text-muted-foreground divide-y">
|
|
||||||
{recipients.length === 0 && (
|
|
||||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
|
||||||
<Trans>No recipients</Trans>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{recipients.map((recipient) => (
|
|
||||||
<li
|
|
||||||
key={recipient.id}
|
|
||||||
className="flex items-center justify-between px-4 py-3 text-sm"
|
|
||||||
>
|
|
||||||
<AvatarWithText
|
|
||||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
|
||||||
primaryText={
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{recipient.email}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
secondaryText={
|
|
||||||
<p className="text-muted-foreground/70 text-xs">
|
|
||||||
{t(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{recipient.role !== RecipientRole.CC && (
|
|
||||||
<CopyTextButton
|
|
||||||
value={formatSigningLink(recipient.token)}
|
|
||||||
onCopySuccess={() => {
|
|
||||||
toast({
|
|
||||||
title: t`Copied to clipboard`,
|
|
||||||
description: t`The signing link has been copied to your clipboard.`,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
badgeContentUncopied={
|
|
||||||
<p className="ml-1 text-xs">
|
|
||||||
<Trans>Copy</Trans>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
badgeContentCopied={
|
|
||||||
<p className="ml-1 text-xs">
|
|
||||||
<Trans>Copied</Trans>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@ -426,12 +407,24 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertDescription>
|
{match(invalidEnvelopeCode)
|
||||||
<Trans>
|
.with('MISSING_RECIPIENTS', () => (
|
||||||
Some signers have not been assigned a signature field. Please assign at least 1
|
<AlertDescription>
|
||||||
signature field to each signer before proceeding.
|
<Trans>You need at least one recipient to send a document</Trans>
|
||||||
</Trans>
|
</AlertDescription>
|
||||||
</AlertDescription>
|
))
|
||||||
|
.with('MISSING_SIGNATURES', () => (
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>The following signers are missing signature fields:</Trans>
|
||||||
|
|
||||||
|
<ul className="ml-2 mt-1 list-inside list-disc">
|
||||||
|
{recipientsMissingSignatureFields.map((recipient) => (
|
||||||
|
<li key={recipient.id}>{recipient.email}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
204
apps/remix/app/components/dialogs/envelope-download-dialog.tsx
Normal file
204
apps/remix/app/components/dialogs/envelope-download-dialog.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||||
|
import { DownloadIcon, FileTextIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' | 'order'>;
|
||||||
|
|
||||||
|
type EnvelopeDownloadDialogProps = {
|
||||||
|
envelopeId: string;
|
||||||
|
envelopeStatus: DocumentStatus;
|
||||||
|
envelopeItems?: EnvelopeItemToDownload[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recipient token to download the document.
|
||||||
|
*
|
||||||
|
* If not provided, it will be assumed that the current user can access the document.
|
||||||
|
*/
|
||||||
|
token?: string;
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnvelopeDownloadDialog = ({
|
||||||
|
envelopeId,
|
||||||
|
envelopeStatus,
|
||||||
|
envelopeItems: initialEnvelopeItems,
|
||||||
|
token,
|
||||||
|
trigger,
|
||||||
|
}: EnvelopeDownloadDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const [isDownloadingState, setIsDownloadingState] = useState<{
|
||||||
|
[envelopeItemIdAndVersion: string]: boolean;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
const generateDownloadKey = (envelopeItemId: string, version: 'original' | 'signed') =>
|
||||||
|
`${envelopeItemId}-${version}`;
|
||||||
|
|
||||||
|
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||||
|
trpc.envelope.item.getManyByToken.useQuery(
|
||||||
|
{
|
||||||
|
envelopeId,
|
||||||
|
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialData: initialEnvelopeItems ? { data: initialEnvelopeItems } : undefined,
|
||||||
|
enabled: open,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const envelopeItems = envelopeItemsPayload?.data || [];
|
||||||
|
|
||||||
|
const onDownload = async (
|
||||||
|
envelopeItem: EnvelopeItemToDownload,
|
||||||
|
version: 'original' | 'signed',
|
||||||
|
) => {
|
||||||
|
const { id: envelopeItemId } = envelopeItem;
|
||||||
|
|
||||||
|
if (isDownloadingState[generateDownloadKey(envelopeItemId, version)]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDownloadingState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[generateDownloadKey(envelopeItemId, version)]: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadPDF({
|
||||||
|
envelopeItem,
|
||||||
|
token,
|
||||||
|
fileName: envelopeItem.title,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsDownloadingState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[generateDownloadKey(envelopeItemId, version)]: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
setIsDownloadingState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[generateDownloadKey(envelopeItemId, version)]: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`This document could not be downloaded at this time. Please try again.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Download Files</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Select the files you would like to download.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{isLoadingEnvelopeItems ? (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: 1 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
<Skeleton className="h-4 w-28 rounded-lg" />
|
||||||
|
<Skeleton className="h-4 w-20 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
envelopeItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="border-border bg-card hover:bg-accent/50 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||||
|
<FileTextIcon className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Todo: Envelopes - Fix overflow */}
|
||||||
|
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
<Trans>PDF Document</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={async () => onDownload(item, 'original')}
|
||||||
|
loading={isDownloadingState[generateDownloadKey(item.id, 'original')]}
|
||||||
|
>
|
||||||
|
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
|
||||||
|
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<Trans>Original</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{envelopeStatus === DocumentStatus.COMPLETED && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={async () => onDownload(item, 'signed')}
|
||||||
|
loading={isDownloadingState[generateDownloadKey(item.id, 'signed')]}
|
||||||
|
>
|
||||||
|
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
|
||||||
|
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<Trans>Signed</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -43,7 +43,7 @@ export const EnvelopeDuplicateDialog = ({
|
|||||||
|
|
||||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||||
trpc.envelope.duplicate.useMutation({
|
trpc.envelope.duplicate.useMutation({
|
||||||
onSuccess: async ({ duplicatedEnvelopeId }) => {
|
onSuccess: async ({ id }) => {
|
||||||
toast({
|
toast({
|
||||||
title: t`Envelope Duplicated`,
|
title: t`Envelope Duplicated`,
|
||||||
description: t`Your envelope has been successfully duplicated.`,
|
description: t`Your envelope has been successfully duplicated.`,
|
||||||
@ -55,7 +55,7 @@ export const EnvelopeDuplicateDialog = ({
|
|||||||
? formatDocumentsPath(team.url)
|
? formatDocumentsPath(team.url)
|
||||||
: formatTemplatesPath(team.url);
|
: formatTemplatesPath(team.url);
|
||||||
|
|
||||||
await navigate(`${path}/${duplicatedEnvelopeId}/edit`);
|
await navigate(`${path}/${id}/edit`);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -185,6 +185,10 @@ export const OrganisationMemberInviteDialog = ({
|
|||||||
return 'form';
|
return 'form';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fullOrganisation.members.length < fullOrganisation.organisationClaim.memberCount) {
|
||||||
|
return 'form';
|
||||||
|
}
|
||||||
|
|
||||||
// This is probably going to screw us over in the future.
|
// This is probably going to screw us over in the future.
|
||||||
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
|
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
|
||||||
return 'alert';
|
return 'alert';
|
||||||
|
|||||||
186
apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
Normal file
186
apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import { createCallable } from 'react-call';
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||||
|
import { type TCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||||
|
|
||||||
|
export type SignFieldCheckboxDialogProps = {
|
||||||
|
fieldMeta: TCheckboxFieldMeta;
|
||||||
|
validationRule: '>=' | '=' | '<=';
|
||||||
|
validationLength: number;
|
||||||
|
preselectedIndices: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SignFieldCheckboxDialog = createCallable<
|
||||||
|
SignFieldCheckboxDialogProps,
|
||||||
|
number[] | null
|
||||||
|
>(({ call, fieldMeta, validationRule, validationLength, preselectedIndices }) => {
|
||||||
|
const ZSignFieldCheckboxFormSchema = z
|
||||||
|
.object({
|
||||||
|
values: z.array(
|
||||||
|
z.object({
|
||||||
|
checked: z.boolean(),
|
||||||
|
value: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
// Allow unselecting all options if the field is not required even if
|
||||||
|
// validation is not met.
|
||||||
|
if (!fieldMeta.required && data.values.every((value) => !value.checked)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberOfSelectedValues = data.values.filter((value) => value.checked).length;
|
||||||
|
|
||||||
|
const isValid = validateCheckboxLength(
|
||||||
|
numberOfSelectedValues,
|
||||||
|
validationRule,
|
||||||
|
validationLength,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: msg`Validation failed`.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof ZSignFieldCheckboxFormSchema>>({
|
||||||
|
resolver: zodResolver(ZSignFieldCheckboxFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
values: (fieldMeta.values || []).map((value, index) => ({
|
||||||
|
checked: preselectedIndices.includes(index) || false,
|
||||||
|
value: value.value,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formValues = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Sign Checkbox Field</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription
|
||||||
|
className={cn('mt-4', {
|
||||||
|
'text-destructive': Object.keys(form.formState.errors).length > 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{match(validationRule)
|
||||||
|
.with('>=', () => (
|
||||||
|
<Plural
|
||||||
|
value={validationLength}
|
||||||
|
one="Select at least # option"
|
||||||
|
other="Select at least # options"
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with('=', () => (
|
||||||
|
<Plural
|
||||||
|
value={validationLength}
|
||||||
|
one="Select exactly # option"
|
||||||
|
other="Select exactly # options"
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with('<=', () => (
|
||||||
|
<Plural
|
||||||
|
value={validationLength}
|
||||||
|
one="Select at most # option"
|
||||||
|
other="Select at most # options"
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit((data) =>
|
||||||
|
call.end(
|
||||||
|
data.values
|
||||||
|
.map((value, i) => (value.checked ? i : null))
|
||||||
|
.filter((value) => value !== null),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{(formValues.values || []).map((value, index) => (
|
||||||
|
<li key={`checkbox-${index}`}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`values.${index}`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
id={`checkbox-value-${index}`}
|
||||||
|
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
|
||||||
|
checked={field.value.checked}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
field.onChange({
|
||||||
|
...field.value,
|
||||||
|
checked,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="text-muted-foreground ml-2 w-full text-sm"
|
||||||
|
htmlFor={`checkbox-value-${index}`}
|
||||||
|
>
|
||||||
|
{value.value}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit">
|
||||||
|
<Trans>Sign</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -1,40 +1,15 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
import { createCallable } from 'react-call';
|
import { createCallable } from 'react-call';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
CommandDialog,
|
||||||
DialogContent,
|
CommandEmpty,
|
||||||
DialogDescription,
|
CommandGroup,
|
||||||
DialogFooter,
|
CommandInput,
|
||||||
DialogHeader,
|
CommandItem,
|
||||||
DialogTitle,
|
CommandList,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/command';
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
|
|
||||||
const ZSignFieldDropdownFormSchema = z.object({
|
|
||||||
dropdown: z.string().min(1, { message: msg`Option is required`.id }),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TSignFieldDropdownFormSchema = z.infer<typeof ZSignFieldDropdownFormSchema>;
|
|
||||||
|
|
||||||
export type SignFieldDropdownDialogProps = {
|
export type SignFieldDropdownDialogProps = {
|
||||||
fieldMeta: TDropdownFieldMeta;
|
fieldMeta: TDropdownFieldMeta;
|
||||||
@ -46,72 +21,25 @@ export const SignFieldDropdownDialog = createCallable<SignFieldDropdownDialogPro
|
|||||||
|
|
||||||
const values = fieldMeta.values?.map((value) => value.value) ?? [];
|
const values = fieldMeta.values?.map((value) => value.value) ?? [];
|
||||||
|
|
||||||
const form = useForm<TSignFieldDropdownFormSchema>({
|
|
||||||
resolver: zodResolver(ZSignFieldDropdownFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
dropdown: fieldMeta.defaultValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
<CommandDialog
|
||||||
<DialogContent position="center">
|
position="start"
|
||||||
<DialogHeader>
|
dialogContentClassName="mt-4"
|
||||||
<DialogTitle>
|
open={true}
|
||||||
<Trans>Sign Dropdown Field</Trans>
|
onOpenChange={(value) => (!value ? call.end(null) : null)}
|
||||||
</DialogTitle>
|
>
|
||||||
|
<CommandInput placeholder={t`Select an option`} />
|
||||||
<DialogDescription className="mt-4">
|
<CommandList>
|
||||||
<Trans>Select a value to sign into the field</Trans>
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
</DialogDescription>
|
<CommandGroup heading={t`Options`}>
|
||||||
</DialogHeader>
|
{values.map((value, i) => (
|
||||||
|
<CommandItem onSelect={() => call.end(value)} key={i} value={value}>
|
||||||
<Form {...form}>
|
{value}
|
||||||
<form onSubmit={form.handleSubmit((data) => call.end(data.dropdown))}>
|
</CommandItem>
|
||||||
<fieldset
|
))}
|
||||||
className="flex h-full flex-col space-y-4"
|
</CommandGroup>
|
||||||
disabled={form.formState.isSubmitting}
|
</CommandList>
|
||||||
>
|
</CommandDialog>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="dropdown"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="bg-background">
|
|
||||||
<SelectValue placeholder={t`Select an option`} />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
{values.map((value, i) => (
|
|
||||||
<SelectItem key={i} value={value}>
|
|
||||||
{value}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit">
|
|
||||||
<Trans>Sign</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -29,20 +29,22 @@ const ZSignFieldEmailFormSchema = z.object({
|
|||||||
|
|
||||||
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
|
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
|
||||||
|
|
||||||
export type SignFieldEmailDialogProps = Record<string, never>;
|
export type SignFieldEmailDialogProps = {
|
||||||
|
placeholderEmail: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, string | null>(
|
export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, string | null>(
|
||||||
({ call }) => {
|
({ call, placeholderEmail }) => {
|
||||||
const form = useForm<TSignFieldEmailFormSchema>({
|
const form = useForm<TSignFieldEmailFormSchema>({
|
||||||
resolver: zodResolver(ZSignFieldEmailFormSchema),
|
resolver: zodResolver(ZSignFieldEmailFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: '',
|
email: placeholderEmail || '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
<DialogContent position="center">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Sign Email</Trans>
|
<Trans>Sign Email</Trans>
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
<DialogContent position="center">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Sign Initials</Trans>
|
<Trans>Sign Initials</Trans>
|
||||||
|
|||||||
@ -44,7 +44,7 @@ export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, stri
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
<DialogContent position="center">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Sign Name</Trans>
|
<Trans>Sign Name</Trans>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
|
|
||||||
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
||||||
let schema = z.coerce.number({
|
let schema = z.coerce.number({
|
||||||
invalid_type_error: msg`Please enter a valid number`.id, // Todo: Envelopes - Check that this works
|
invalid_type_error: msg`Please enter a valid number`.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { numberFormat, minValue, maxValue } = fieldMeta;
|
const { numberFormat, minValue, maxValue } = fieldMeta;
|
||||||
@ -55,9 +55,7 @@ const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
|||||||
return foundRegex.test(value.toString());
|
return foundRegex.test(value.toString());
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: `Number needs to be formatted as ${numberFormat}`,
|
message: msg`Number needs to be formatted as ${numberFormat}`.id,
|
||||||
// Todo: Envelopes
|
|
||||||
// message: msg`Number needs to be formatted as ${numberFormat}`.id,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -86,7 +84,7 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
<DialogContent position="center">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Sign Number Field</Trans>
|
<Trans>Sign Number Field</Trans>
|
||||||
|
|||||||
@ -50,7 +50,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
<DialogContent position="center">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Sign Text Field</Trans>
|
<Trans>Sign Text Field</Trans>
|
||||||
|
|||||||
@ -7,9 +7,9 @@ import { FilePlus, Loader } from 'lucide-react';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -54,13 +54,17 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
|
|||||||
setIsUploadingFile(true);
|
setIsUploadingFile(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await putPdfFile(file);
|
const payload = {
|
||||||
|
|
||||||
const { legacyTemplateId: id } = await createTemplate({
|
|
||||||
title: file.name,
|
title: file.name,
|
||||||
templateDocumentDataId: response.id,
|
|
||||||
folderId: folderId,
|
folderId: folderId,
|
||||||
});
|
} satisfies TCreateTemplatePayloadSchema;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const { envelopeId: id } = await createTemplate(formData);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Template document uploaded`),
|
title: _(msg`Template document uploaded`),
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Recipient } from '@prisma/client';
|
import type { Recipient } from '@prisma/client';
|
||||||
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
|
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
|
||||||
import { InfoIcon, Plus, Upload, X } from 'lucide-react';
|
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
@ -16,6 +16,10 @@ import {
|
|||||||
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
||||||
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||||
} from '@documenso/lib/constants/template';
|
} from '@documenso/lib/constants/template';
|
||||||
|
import {
|
||||||
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
SKIP_QUERY_BATCH_META,
|
||||||
|
} from '@documenso/lib/constants/trpc';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -41,6 +45,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@ -49,8 +54,13 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
|
|||||||
distributeDocument: z.boolean(),
|
distributeDocument: z.boolean(),
|
||||||
useCustomDocument: z.boolean().default(false),
|
useCustomDocument: z.boolean().default(false),
|
||||||
customDocumentData: z
|
customDocumentData: z
|
||||||
.any()
|
.array(
|
||||||
.refine((data) => data instanceof File || data === undefined)
|
z.object({
|
||||||
|
title: z.string(),
|
||||||
|
data: z.instanceof(File).optional(),
|
||||||
|
envelopeItemId: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
recipients: z.array(
|
recipients: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@ -65,6 +75,7 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
|
|||||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||||
|
|
||||||
export type TemplateUseDialogProps = {
|
export type TemplateUseDialogProps = {
|
||||||
|
envelopeId: string;
|
||||||
templateId: number;
|
templateId: number;
|
||||||
templateSigningOrder?: DocumentSigningOrder | null;
|
templateSigningOrder?: DocumentSigningOrder | null;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
@ -77,6 +88,7 @@ export function TemplateUseDialog({
|
|||||||
recipients,
|
recipients,
|
||||||
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
|
envelopeId,
|
||||||
templateId,
|
templateId,
|
||||||
templateSigningOrder,
|
templateSigningOrder,
|
||||||
trigger,
|
trigger,
|
||||||
@ -93,7 +105,7 @@ export function TemplateUseDialog({
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
distributeDocument: false,
|
distributeDocument: false,
|
||||||
useCustomDocument: false,
|
useCustomDocument: false,
|
||||||
customDocumentData: undefined,
|
customDocumentData: [],
|
||||||
recipients: recipients
|
recipients: recipients
|
||||||
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
|
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
|
||||||
.map((recipient) => {
|
.map((recipient) => {
|
||||||
@ -115,23 +127,50 @@ export function TemplateUseDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { replace, fields: localCustomDocumentData } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: 'customDocumentData',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
|
||||||
|
{
|
||||||
|
envelopeId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
|
...SKIP_QUERY_BATCH_META,
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const envelopeItems = response?.data ?? [];
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromTemplate } =
|
const { mutateAsync: createDocumentFromTemplate } =
|
||||||
trpc.template.createDocumentFromTemplate.useMutation();
|
trpc.template.createDocumentFromTemplate.useMutation();
|
||||||
|
|
||||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||||
try {
|
try {
|
||||||
let customDocumentDataId: string | undefined = undefined;
|
const customFilesToUpload = (data.customDocumentData || []).filter(
|
||||||
|
(item): item is { data: File; envelopeItemId: string; title: string } =>
|
||||||
|
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
if (data.useCustomDocument && data.customDocumentData) {
|
const customDocumentData = await Promise.all(
|
||||||
const customDocumentData = await putPdfFile(data.customDocumentData);
|
customFilesToUpload.map(async (item) => {
|
||||||
customDocumentDataId = customDocumentData.id;
|
const customDocumentData = await putPdfFile(item.data);
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await createDocumentFromTemplate({
|
return {
|
||||||
|
documentDataId: customDocumentData.id,
|
||||||
|
envelopeItemId: item.envelopeItemId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { envelopeId } = await createDocumentFromTemplate({
|
||||||
templateId,
|
templateId,
|
||||||
recipients: data.recipients,
|
recipients: data.recipients,
|
||||||
distributeDocument: data.distributeDocument,
|
distributeDocument: data.distributeDocument,
|
||||||
customDocumentDataId,
|
customDocumentData,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -140,7 +179,7 @@ export function TemplateUseDialog({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
let documentPath = `${documentRootPath}/${id}`;
|
let documentPath = `${documentRootPath}/${envelopeId}`;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
data.distributeDocument &&
|
data.distributeDocument &&
|
||||||
@ -180,6 +219,18 @@ export function TemplateUseDialog({
|
|||||||
}
|
}
|
||||||
}, [open, form]);
|
}, [open, form]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (envelopeItems.length > 0 && localCustomDocumentData.length === 0) {
|
||||||
|
replace(
|
||||||
|
envelopeItems.map((item) => ({
|
||||||
|
title: item.title,
|
||||||
|
data: undefined,
|
||||||
|
envelopeItemId: item.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [envelopeItems, form, open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@ -384,7 +435,6 @@ export function TemplateUseDialog({
|
|||||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||||
htmlFor="useCustomDocument"
|
htmlFor="useCustomDocument"
|
||||||
>
|
>
|
||||||
{/* Todo: Envelopes - How will this work? */}
|
|
||||||
<Trans>Upload custom document</Trans>
|
<Trans>Upload custom document</Trans>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger type="button">
|
||||||
@ -406,116 +456,133 @@ export function TemplateUseDialog({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{form.watch('useCustomDocument') && (
|
{form.watch('useCustomDocument') && (
|
||||||
<div className="my-4">
|
<div className="my-4 space-y-2">
|
||||||
<FormField
|
{isLoadingEnvelopeItems ? (
|
||||||
control={form.control}
|
<SpinnerBox className="py-16" />
|
||||||
name="customDocumentData"
|
) : (
|
||||||
render={({ field }) => (
|
localCustomDocumentData.map((item, i) => (
|
||||||
<FormItem>
|
<FormField
|
||||||
<FormControl>
|
key={item.id}
|
||||||
<div className="w-full space-y-4">
|
control={form.control}
|
||||||
<label
|
name={`customDocumentData.${i}.data`}
|
||||||
className={cn(
|
render={({ field }) => (
|
||||||
'text-muted-foreground hover:border-muted-foreground/50 group relative flex min-h-[150px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 px-6 py-10 transition-colors',
|
<FormItem>
|
||||||
{
|
<FormControl>
|
||||||
'border-destructive hover:border-destructive':
|
<div
|
||||||
form.formState.errors.customDocumentData,
|
key={item.id}
|
||||||
},
|
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||||
)}
|
>
|
||||||
>
|
<div className="flex-shrink-0">
|
||||||
<div className="text-center">
|
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||||
{!field.value && (
|
<FileTextIcon className="text-primary h-5 w-5" />
|
||||||
<>
|
|
||||||
<Upload className="text-muted-foreground/50 mx-auto h-10 w-10" />
|
|
||||||
<div className="mt-4 flex text-sm leading-6">
|
|
||||||
<span className="text-muted-foreground relative">
|
|
||||||
<Trans>
|
|
||||||
<span className="text-primary font-semibold">
|
|
||||||
Click to upload
|
|
||||||
</span>{' '}
|
|
||||||
or drag and drop
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground/80 text-xs">
|
|
||||||
PDF files only
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{field.value && (
|
|
||||||
<div className="text-muted-foreground space-y-1">
|
|
||||||
<p className="text-sm font-medium">{field.value.name}</p>
|
|
||||||
<p className="text-muted-foreground/60 text-xs">
|
|
||||||
{(field.value.size / (1024 * 1024)).toFixed(2)} MB
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
data-testid="template-use-dialog-file-input"
|
|
||||||
className="absolute h-full w-full opacity-0"
|
|
||||||
accept=".pdf,application/pdf"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
field.onChange(undefined);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.type !== 'application/pdf') {
|
|
||||||
form.setError('customDocumentData', {
|
|
||||||
type: 'manual',
|
|
||||||
message: _(msg`Please select a PDF file`),
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
|
|
||||||
form.setError('customDocumentData', {
|
|
||||||
type: 'manual',
|
|
||||||
message: _(
|
|
||||||
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
field.onChange(file);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{field.value && (
|
|
||||||
<div className="absolute right-2 top-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
field.onChange(undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<div className="sr-only">
|
|
||||||
<Trans>Clear file</Trans>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</label>
|
<div className="min-w-0 flex-1">
|
||||||
</div>
|
<h4 className="text-foreground truncate text-sm font-medium">
|
||||||
</FormControl>
|
{item.title}
|
||||||
<FormMessage />
|
</h4>
|
||||||
</FormItem>
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
)}
|
{field.value ? (
|
||||||
/>
|
<div>
|
||||||
|
<Trans>
|
||||||
|
Custom {(field.value.size / (1024 * 1024)).toFixed(2)}{' '}
|
||||||
|
MB file
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Trans>Default file</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
{field.value ? (
|
||||||
|
<div className="">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
field.onChange(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Remove</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
const fileInput = document.getElementById(
|
||||||
|
`template-use-dialog-file-input-${item.envelopeItemId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fileInput instanceof HTMLInputElement) {
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UploadCloudIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Upload</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id={`template-use-dialog-file-input-${item.envelopeItemId}`}
|
||||||
|
className="hidden"
|
||||||
|
accept=".pdf,application/pdf"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
field.onChange(undefined);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type !== 'application/pdf') {
|
||||||
|
form.setError('customDocumentData', {
|
||||||
|
type: 'manual',
|
||||||
|
message: _(msg`Please select a PDF file`),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
file.size >
|
||||||
|
APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024
|
||||||
|
) {
|
||||||
|
form.setError('customDocumentData', {
|
||||||
|
type: 'manual',
|
||||||
|
message: _(
|
||||||
|
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
field.onChange(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentData, FieldType } from '@prisma/client';
|
import type { DocumentData, FieldType } from '@prisma/client';
|
||||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||||
|
import { base64 } from '@scure/base';
|
||||||
import { ChevronsUpDown } from 'lucide-react';
|
import { ChevronsUpDown } from 'lucide-react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
@ -12,7 +13,6 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
|
|||||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
||||||
@ -83,21 +83,14 @@ export const ConfigureFieldsView = ({
|
|||||||
|
|
||||||
const normalizedDocumentData = useMemo(() => {
|
const normalizedDocumentData = useMemo(() => {
|
||||||
if (documentData) {
|
if (documentData) {
|
||||||
return documentData;
|
return documentData.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!configData.documentData) {
|
if (!configData.documentData) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = base64.encode(configData.documentData?.data);
|
return base64.encode(configData.documentData.data);
|
||||||
|
|
||||||
return {
|
|
||||||
id: 'preview',
|
|
||||||
type: 'BYTES_64',
|
|
||||||
data,
|
|
||||||
initialData: data,
|
|
||||||
} satisfies DocumentData;
|
|
||||||
}, [configData.documentData]);
|
}, [configData.documentData]);
|
||||||
|
|
||||||
const recipients = useMemo(() => {
|
const recipients = useMemo(() => {
|
||||||
@ -541,7 +534,15 @@ export const ConfigureFieldsView = ({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
{normalizedDocumentData && (
|
{normalizedDocumentData && (
|
||||||
<div>
|
<div>
|
||||||
<PDFViewer documentData={normalizedDocumentData} />
|
<PDFViewer
|
||||||
|
overrideData={normalizedDocumentData}
|
||||||
|
envelopeItem={{
|
||||||
|
id: '',
|
||||||
|
envelopeId: '',
|
||||||
|
}}
|
||||||
|
token={undefined}
|
||||||
|
version="signed"
|
||||||
|
/>
|
||||||
|
|
||||||
<ElementVisible
|
<ElementVisible
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export type EmbedAuthenticationRequiredProps = {
|
|||||||
email?: string;
|
email?: string;
|
||||||
returnTo: string;
|
returnTo: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
|
isMicrosoftSSOEnabled?: boolean;
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
oidcProviderLabel?: string;
|
oidcProviderLabel?: string;
|
||||||
};
|
};
|
||||||
@ -17,6 +18,7 @@ export const EmbedAuthenticationRequired = ({
|
|||||||
email,
|
email,
|
||||||
returnTo,
|
returnTo,
|
||||||
// isGoogleSSOEnabled,
|
// isGoogleSSOEnabled,
|
||||||
|
// isMicrosoftSSOEnabled,
|
||||||
// isOIDCSSOEnabled,
|
// isOIDCSSOEnabled,
|
||||||
// oidcProviderLabel,
|
// oidcProviderLabel,
|
||||||
}: EmbedAuthenticationRequiredProps) => {
|
}: EmbedAuthenticationRequiredProps) => {
|
||||||
@ -37,6 +39,7 @@ export const EmbedAuthenticationRequired = ({
|
|||||||
<SignInForm
|
<SignInForm
|
||||||
// Embed currently not supported.
|
// Embed currently not supported.
|
||||||
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
|
// isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||||
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
// oidcProviderLabel={oidcProviderLabel}
|
// oidcProviderLabel={oidcProviderLabel}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentMeta, Recipient, Signature } from '@prisma/client';
|
import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client';
|
||||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
import { type Field, FieldType } from '@prisma/client';
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
@ -37,6 +37,7 @@ import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-sc
|
|||||||
import { injectCss } from '~/utils/css-vars';
|
import { injectCss } from '~/utils/css-vars';
|
||||||
|
|
||||||
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
|
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
|
||||||
|
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||||
import { EmbedClientLoading } from './embed-client-loading';
|
import { EmbedClientLoading } from './embed-client-loading';
|
||||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||||
@ -44,8 +45,9 @@ import { EmbedDocumentFields } from './embed-document-fields';
|
|||||||
|
|
||||||
export type EmbedDirectTemplateClientPageProps = {
|
export type EmbedDirectTemplateClientPageProps = {
|
||||||
token: string;
|
token: string;
|
||||||
|
envelopeId: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
documentData: DocumentData;
|
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
metadata?: DocumentMeta | null;
|
metadata?: DocumentMeta | null;
|
||||||
@ -55,9 +57,10 @@ export type EmbedDirectTemplateClientPageProps = {
|
|||||||
|
|
||||||
export const EmbedDirectTemplateClientPage = ({
|
export const EmbedDirectTemplateClientPage = ({
|
||||||
token,
|
token,
|
||||||
|
envelopeId,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
documentData,
|
envelopeItems,
|
||||||
recipient: _recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
metadata,
|
metadata,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
@ -321,14 +324,20 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||||
|
|
||||||
|
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||||
|
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={recipient.token} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
documentData={documentData}
|
envelopeItem={envelopeItems[0]}
|
||||||
|
token={token}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,14 +3,8 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentMeta } from '@prisma/client';
|
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
|
||||||
import {
|
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
type DocumentData,
|
|
||||||
type Field,
|
|
||||||
FieldType,
|
|
||||||
RecipientRole,
|
|
||||||
SigningStatus,
|
|
||||||
} from '@prisma/client';
|
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||||
@ -37,6 +31,7 @@ import { BrandingLogo } from '~/components/general/branding-logo';
|
|||||||
import { injectCss } from '~/utils/css-vars';
|
import { injectCss } from '~/utils/css-vars';
|
||||||
|
|
||||||
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
||||||
|
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||||
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
||||||
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
|
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
|
||||||
@ -45,10 +40,11 @@ import { EmbedDocumentCompleted } from './embed-document-completed';
|
|||||||
import { EmbedDocumentFields } from './embed-document-fields';
|
import { EmbedDocumentFields } from './embed-document-fields';
|
||||||
import { EmbedDocumentRejected } from './embed-document-rejected';
|
import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||||
|
|
||||||
export type EmbedSignDocumentClientPageProps = {
|
export type EmbedSignDocumentV1ClientPageProps = {
|
||||||
token: string;
|
token: string;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
documentData: DocumentData;
|
envelopeId: string;
|
||||||
|
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
completedFields: DocumentField[];
|
completedFields: DocumentField[];
|
||||||
@ -59,10 +55,11 @@ export type EmbedSignDocumentClientPageProps = {
|
|||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedSignDocumentClientPage = ({
|
export const EmbedSignDocumentV1ClientPage = ({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
documentData,
|
envelopeId,
|
||||||
|
envelopeItems,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
completedFields,
|
completedFields,
|
||||||
@ -71,7 +68,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
allowWhitelabelling = false,
|
allowWhitelabelling = false,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
}: EmbedSignDocumentClientPageProps) => {
|
}: EmbedSignDocumentV1ClientPageProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -274,21 +271,25 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||||
|
|
||||||
{allowDocumentRejection && (
|
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
|
||||||
|
|
||||||
|
{allowDocumentRejection && (
|
||||||
<DocumentSigningRejectDialog
|
<DocumentSigningRejectDialog
|
||||||
documentId={documentId}
|
documentId={documentId}
|
||||||
token={token}
|
token={token}
|
||||||
onRejected={onDocumentRejected}
|
onRejected={onDocumentRejected}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="embed--DocumentViewer flex-1">
|
<div className="embed--DocumentViewer flex-1">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
documentData={documentData}
|
envelopeItem={envelopeItems[0]}
|
||||||
|
token={token}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -0,0 +1,232 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
|
|
||||||
|
import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema';
|
||||||
|
import { injectCss } from '~/utils/css-vars';
|
||||||
|
|
||||||
|
import { DocumentSigningPageViewV2 } from '../general/document-signing/document-signing-page-view-v2';
|
||||||
|
import { useRequiredEnvelopeSigningContext } from '../general/document-signing/envelope-signing-provider';
|
||||||
|
import { EmbedClientLoading } from './embed-client-loading';
|
||||||
|
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||||
|
import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||||
|
import { EmbedSigningProvider } from './embed-signing-context';
|
||||||
|
|
||||||
|
export type EmbedSignDocumentV2ClientPageProps = {
|
||||||
|
hidePoweredBy?: boolean;
|
||||||
|
allowWhitelabelling?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmbedSignDocumentV2ClientPage = ({
|
||||||
|
hidePoweredBy = false,
|
||||||
|
allowWhitelabelling = false,
|
||||||
|
}: EmbedSignDocumentV2ClientPageProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const { envelope, recipient, envelopeData, setFullName, fullName } =
|
||||||
|
useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
const { isCompleted, isRejected, recipientSignature } = envelopeData;
|
||||||
|
|
||||||
|
// !: Not used at the moment, may be removed in the future.
|
||||||
|
// const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||||
|
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||||
|
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||||
|
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||||
|
|
||||||
|
const onDocumentCompleted = (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
}) => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-completed',
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentError = () => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-error',
|
||||||
|
data: null,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentReady = () => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-ready',
|
||||||
|
data: null,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFieldSigned = (data: { fieldId?: number; value?: string; isBase64?: boolean }) => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'field-signed',
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFieldUnsigned = (data: { fieldId?: number }) => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'field-unsigned',
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentRejected = (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
reason?: string;
|
||||||
|
}) => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-rejected',
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
||||||
|
|
||||||
|
if (!isCompleted && data.name) {
|
||||||
|
setFullName(data.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since a recipient can be provided a name we can lock it without requiring
|
||||||
|
// a to be provided by the parent application, unlike direct templates.
|
||||||
|
setIsNameLocked(!!data.lockName);
|
||||||
|
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||||
|
|
||||||
|
if (data.darkModeDisabled) {
|
||||||
|
document.documentElement.classList.add('dark-mode-disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowWhitelabelling) {
|
||||||
|
injectCss({
|
||||||
|
css: data.css,
|
||||||
|
cssVars: data.cssVars,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasFinishedInit(true);
|
||||||
|
|
||||||
|
// !: While the setters are stable we still want to ensure we're avoiding
|
||||||
|
// !: re-renders.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [allowWhitelabelling]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasFinishedInit) {
|
||||||
|
onDocumentReady();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasFinishedInit]);
|
||||||
|
|
||||||
|
// Listen for document completion events from the envelope signing context
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCompleted) {
|
||||||
|
onDocumentCompleted({
|
||||||
|
token: recipient.token,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
recipientId: recipient.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isCompleted, envelope.id, recipient.id, recipient.token]);
|
||||||
|
|
||||||
|
// Listen for document rejection events
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRejected) {
|
||||||
|
onDocumentRejected({
|
||||||
|
token: recipient.token,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
recipientId: recipient.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isRejected, envelope.id, recipient.id, recipient.token]);
|
||||||
|
|
||||||
|
if (isRejected) {
|
||||||
|
return <EmbedDocumentRejected />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompleted) {
|
||||||
|
return (
|
||||||
|
<EmbedDocumentCompleted
|
||||||
|
name={fullName}
|
||||||
|
signature={
|
||||||
|
recipientSignature
|
||||||
|
? {
|
||||||
|
id: 1,
|
||||||
|
fieldId: 1,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
created: new Date(),
|
||||||
|
signatureImageAsBase64: recipientSignature.signatureImageAsBase64,
|
||||||
|
typedSignature: recipientSignature.typedSignature,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbedSigningProvider
|
||||||
|
isNameLocked={isNameLocked}
|
||||||
|
hidePoweredBy={hidePoweredBy}
|
||||||
|
allowDocumentRejection={allowDocumentRejection}
|
||||||
|
onDocumentCompleted={onDocumentCompleted}
|
||||||
|
onDocumentError={onDocumentError}
|
||||||
|
onDocumentRejected={onDocumentRejected}
|
||||||
|
onDocumentReady={onDocumentReady}
|
||||||
|
onFieldSigned={onFieldSigned}
|
||||||
|
onFieldUnsigned={onFieldUnsigned}
|
||||||
|
>
|
||||||
|
<div className="embed--Root relative">
|
||||||
|
{!hasFinishedInit && <EmbedClientLoading />}
|
||||||
|
|
||||||
|
<DocumentSigningPageViewV2 />
|
||||||
|
</div>
|
||||||
|
</EmbedSigningProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
101
apps/remix/app/components/embed/embed-signing-context.tsx
Normal file
101
apps/remix/app/components/embed/embed-signing-context.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export type EmbedSigningContextValue = {
|
||||||
|
isEmbed: true;
|
||||||
|
allowDocumentRejection: boolean;
|
||||||
|
isNameLocked: boolean;
|
||||||
|
isEmailLocked: boolean;
|
||||||
|
hidePoweredBy: boolean;
|
||||||
|
onDocumentCompleted: (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
}) => void;
|
||||||
|
onDocumentError: () => void;
|
||||||
|
onDocumentRejected: (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
reason?: string;
|
||||||
|
}) => void;
|
||||||
|
onDocumentReady: () => void;
|
||||||
|
onFieldSigned: (data: { fieldId?: number; value?: string; isBase64?: boolean }) => void;
|
||||||
|
onFieldUnsigned: (data: { fieldId?: number }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmbedSigningContext = createContext<EmbedSigningContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useEmbedSigningContext = () => {
|
||||||
|
return useContext(EmbedSigningContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRequiredEmbedSigningContext = () => {
|
||||||
|
const context = useEmbedSigningContext();
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useRequiredEmbedSigningContext must be used within EmbedSigningProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EmbedSigningProviderProps = {
|
||||||
|
allowDocumentRejection?: boolean;
|
||||||
|
isNameLocked?: boolean;
|
||||||
|
isEmailLocked?: boolean;
|
||||||
|
hidePoweredBy?: boolean;
|
||||||
|
onDocumentCompleted: (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
}) => void;
|
||||||
|
onDocumentError: () => void;
|
||||||
|
onDocumentRejected: (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
reason?: string;
|
||||||
|
}) => void;
|
||||||
|
onDocumentReady: () => void;
|
||||||
|
onFieldSigned: (data: { fieldId?: number; value?: string; isBase64?: boolean }) => void;
|
||||||
|
onFieldUnsigned: (data: { fieldId?: number }) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmbedSigningProvider = ({
|
||||||
|
allowDocumentRejection = false,
|
||||||
|
isNameLocked = false,
|
||||||
|
isEmailLocked = true,
|
||||||
|
hidePoweredBy = false,
|
||||||
|
onDocumentCompleted,
|
||||||
|
onDocumentError,
|
||||||
|
onDocumentRejected,
|
||||||
|
onDocumentReady,
|
||||||
|
onFieldSigned,
|
||||||
|
onFieldUnsigned,
|
||||||
|
children,
|
||||||
|
}: EmbedSigningProviderProps) => {
|
||||||
|
return (
|
||||||
|
<EmbedSigningContext.Provider
|
||||||
|
value={{
|
||||||
|
isEmbed: true,
|
||||||
|
allowDocumentRejection,
|
||||||
|
isNameLocked,
|
||||||
|
isEmailLocked,
|
||||||
|
hidePoweredBy,
|
||||||
|
onDocumentCompleted,
|
||||||
|
onDocumentError,
|
||||||
|
onDocumentRejected,
|
||||||
|
onDocumentReady,
|
||||||
|
onFieldSigned,
|
||||||
|
onFieldUnsigned,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</EmbedSigningContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -226,7 +226,9 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
documentData={document.documentData}
|
envelopeItem={document.envelopeItems[0]}
|
||||||
|
token={token}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => {
|
onDocumentLoad={() => {
|
||||||
setHasDocumentLoaded(true);
|
setHasDocumentLoaded(true);
|
||||||
onDocumentReady?.();
|
onDocumentReady?.();
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { TeamGlobalSettings } from '@prisma/client';
|
import type { TeamGlobalSettings } from '@prisma/client';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -29,6 +29,8 @@ import {
|
|||||||
} from '@documenso/ui/primitives/select';
|
} from '@documenso/ui/primitives/select';
|
||||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
|
||||||
@ -68,6 +70,9 @@ export function BrandingPreferencesForm({
|
|||||||
}: BrandingPreferencesFormProps) {
|
}: BrandingPreferencesFormProps) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||||
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
||||||
|
|
||||||
@ -88,14 +93,13 @@ export function BrandingPreferencesForm({
|
|||||||
const file = JSON.parse(settings.brandingLogo);
|
const file = JSON.parse(settings.brandingLogo);
|
||||||
|
|
||||||
if ('type' in file && 'data' in file) {
|
if ('type' in file && 'data' in file) {
|
||||||
void getFile(file).then((binaryData) => {
|
const logoUrl =
|
||||||
const objectUrl = URL.createObjectURL(new Blob([binaryData]));
|
context === 'Team'
|
||||||
|
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
|
||||||
|
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
|
||||||
|
|
||||||
setPreviewUrl(objectUrl);
|
setPreviewUrl(logoUrl);
|
||||||
setHasLoadedPreview(true);
|
setHasLoadedPreview(true);
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
// export const numberFormatValues = [
|
|
||||||
// {
|
|
||||||
// label: '123,456,789.00',
|
|
||||||
// value: '123,456,789.00',
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// label: '123.456.789,00',
|
|
||||||
// value: '123.456.789,00',
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// label: '123456,789.00',
|
|
||||||
// value: '123456,789.00',
|
|
||||||
// },
|
|
||||||
// ];
|
|
||||||
|
|
||||||
export const checkboxValidationRules = ['Select at least', 'Select exactly', 'Select at most'];
|
|
||||||
export const checkboxValidationLength = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
|
||||||
export const checkboxValidationSigns = [
|
|
||||||
{
|
|
||||||
label: 'Select at least',
|
|
||||||
value: '>=',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Select exactly',
|
|
||||||
value: '=',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Select at most',
|
|
||||||
value: '<=',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
@ -7,11 +7,19 @@ import { PlusIcon, Trash } from 'lucide-react';
|
|||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||||
import {
|
import {
|
||||||
type TCheckboxFieldMeta as CheckboxFieldMeta,
|
type TCheckboxFieldMeta as CheckboxFieldMeta,
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
ZCheckboxFieldMeta,
|
ZCheckboxFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
|
import {
|
||||||
|
checkboxValidationLength,
|
||||||
|
checkboxValidationRules,
|
||||||
|
checkboxValidationSigns,
|
||||||
|
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@ -30,8 +38,8 @@ import {
|
|||||||
} from '@documenso/ui/primitives/select';
|
} from '@documenso/ui/primitives/select';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
|
|
||||||
import { checkboxValidationLength, checkboxValidationRules } from './constants';
|
|
||||||
import {
|
import {
|
||||||
|
EditorGenericFontSizeField,
|
||||||
EditorGenericReadOnlyField,
|
EditorGenericReadOnlyField,
|
||||||
EditorGenericRequiredField,
|
EditorGenericRequiredField,
|
||||||
} from './editor-field-generic-field-forms';
|
} from './editor-field-generic-field-forms';
|
||||||
@ -44,6 +52,7 @@ const ZCheckboxFieldFormSchema = ZCheckboxFieldMeta.pick({
|
|||||||
required: true,
|
required: true,
|
||||||
values: true,
|
values: true,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
|
fontSize: true,
|
||||||
})
|
})
|
||||||
.extend({
|
.extend({
|
||||||
validationLength: z.coerce.number().optional(),
|
validationLength: z.coerce.number().optional(),
|
||||||
@ -90,6 +99,7 @@ export const EditorFieldCheckboxForm = ({
|
|||||||
values: value.values || [{ id: 1, checked: false, value: '' }],
|
values: value.values || [{ id: 1, checked: false, value: '' }],
|
||||||
required: value.required || false,
|
required: value.required || false,
|
||||||
readOnly: value.readOnly || false,
|
readOnly: value.readOnly || false,
|
||||||
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -99,13 +109,17 @@ export const EditorFieldCheckboxForm = ({
|
|||||||
control,
|
control,
|
||||||
});
|
});
|
||||||
|
|
||||||
const addValue = () => {
|
const addValue = (numberOfValues: number = 1) => {
|
||||||
const currentValues = form.getValues('values') || [];
|
const currentValues = form.getValues('values') || [];
|
||||||
const newId =
|
const currentMaxId = Math.max(...currentValues.map((val) => val.id));
|
||||||
currentValues.length > 0 ? Math.max(...currentValues.map((val) => val.id)) + 1 : 1;
|
|
||||||
|
|
||||||
const newValues = [...currentValues, { id: newId, checked: false, value: '' }];
|
const newValues = Array.from({ length: numberOfValues }, (_, index) => ({
|
||||||
form.setValue('values', newValues);
|
id: currentMaxId + index + 1,
|
||||||
|
checked: false,
|
||||||
|
value: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
form.setValue('values', [...currentValues, ...newValues]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeValue = (index: number) => {
|
const removeValue = (index: number) => {
|
||||||
@ -132,10 +146,34 @@ export const EditorFieldCheckboxForm = ({
|
|||||||
}
|
}
|
||||||
}, [formValues]);
|
}, [formValues]);
|
||||||
|
|
||||||
|
const isValidationRuleMetForPreselectedValues = useMemo(() => {
|
||||||
|
const preselectedValues = (formValues.values || [])?.filter((value) => value.checked);
|
||||||
|
|
||||||
|
if (formValues.validationLength && formValues.validationRule && preselectedValues.length > 0) {
|
||||||
|
const validationRule = checkboxValidationSigns.find(
|
||||||
|
(sign) => sign.label === formValues.validationRule,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validationRule) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateCheckboxLength(
|
||||||
|
preselectedValues.length,
|
||||||
|
validationRule.value,
|
||||||
|
formValues.validationLength,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, [formValues]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form>
|
<form>
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<EditorGenericFontSizeField formControl={form.control} />
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="direction"
|
name="direction"
|
||||||
@ -202,7 +240,25 @@ export const EditorFieldCheckboxForm = ({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
value={field.value ? String(field.value) : ''}
|
value={field.value ? String(field.value) : ''}
|
||||||
onValueChange={field.onChange}
|
onValueChange={(value) => {
|
||||||
|
const validationNumber = Number(value);
|
||||||
|
|
||||||
|
const currentValues = formValues.values || [];
|
||||||
|
|
||||||
|
const minimumNumberOfValuesRequired =
|
||||||
|
validationNumber - currentValues.length;
|
||||||
|
|
||||||
|
if (!formValues.validationRule) {
|
||||||
|
form.setValue('validationRule', checkboxValidationRules[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minimumNumberOfValuesRequired > 0) {
|
||||||
|
addValue(minimumNumberOfValuesRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
field.onChange(validationNumber);
|
||||||
|
void form.trigger();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="text-muted-foreground bg-background mt-5 w-full">
|
<SelectTrigger className="text-muted-foreground bg-background mt-5 w-full">
|
||||||
<SelectValue placeholder={t`Pick a number`} />
|
<SelectValue placeholder={t`Pick a number`} />
|
||||||
@ -239,7 +295,7 @@ export const EditorFieldCheckboxForm = ({
|
|||||||
<Trans>Checkbox values</Trans>
|
<Trans>Checkbox values</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button type="button" onClick={addValue}>
|
<button type="button" onClick={() => addValue()}>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -285,6 +341,16 @@ export const EditorFieldCheckboxForm = ({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{!isValidationRuleMetForPreselectedValues && (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>
|
||||||
|
The preselected values will be ignored unless they meet the validation criteria.
|
||||||
|
</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -8,7 +8,10 @@ import { PlusIcon, Trash } from 'lucide-react';
|
|||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { type TDropdownFieldMeta as DropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
import {
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
type TDropdownFieldMeta as DropdownFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@ -28,56 +31,50 @@ import {
|
|||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
EditorGenericFontSizeField,
|
||||||
EditorGenericReadOnlyField,
|
EditorGenericReadOnlyField,
|
||||||
EditorGenericRequiredField,
|
EditorGenericRequiredField,
|
||||||
} from './editor-field-generic-field-forms';
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
const ZDropdownFieldFormSchema = z
|
const ZDropdownFieldFormSchema = z.object({
|
||||||
.object({
|
defaultValue: z.string().optional(),
|
||||||
defaultValue: z.string().optional(),
|
values: z
|
||||||
values: z
|
.object({
|
||||||
.object({
|
value: z.string().min(1, {
|
||||||
value: z.string().min(1, {
|
message: msg`Option value cannot be empty`.id,
|
||||||
message: msg`Option value cannot be empty`.id,
|
}),
|
||||||
}),
|
})
|
||||||
})
|
.array()
|
||||||
.array()
|
.min(1, {
|
||||||
.min(1, {
|
message: msg`Dropdown must have at least one option`.id,
|
||||||
message: msg`Dropdown must have at least one option`.id,
|
})
|
||||||
})
|
.superRefine((values, ctx) => {
|
||||||
.refine(
|
const seen = new Map<string, number[]>(); // value → indices
|
||||||
(data) => {
|
|
||||||
// Todo: Envelopes - This doesn't work.
|
|
||||||
console.log({
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data) {
|
values.forEach((item, index) => {
|
||||||
const values = data.map((item) => item.value);
|
const key = item.value;
|
||||||
return new Set(values).size === values.length;
|
if (!seen.has(key)) {
|
||||||
|
seen.set(key, []);
|
||||||
|
}
|
||||||
|
seen.get(key)!.push(index);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [key, indices] of seen) {
|
||||||
|
if (indices.length > 1 && key.trim() !== '') {
|
||||||
|
for (const i of indices) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: msg`Duplicate values are not allowed`.id,
|
||||||
|
path: [i, 'value'],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return true;
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
message: 'Duplicate values are not allowed',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
required: z.boolean().optional(),
|
|
||||||
readOnly: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
// Default value must be one of the available options
|
|
||||||
if (data.defaultValue && data.values) {
|
|
||||||
return data.values.some((item) => item.value === data.defaultValue);
|
|
||||||
}
|
}
|
||||||
return true;
|
}),
|
||||||
},
|
required: z.boolean().optional(),
|
||||||
{
|
readOnly: z.boolean().optional(),
|
||||||
message: 'Default value must be one of the available options',
|
fontSize: z.number().optional(),
|
||||||
path: ['defaultValue'],
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
type TDropdownFieldFormSchema = z.infer<typeof ZDropdownFieldFormSchema>;
|
type TDropdownFieldFormSchema = z.infer<typeof ZDropdownFieldFormSchema>;
|
||||||
|
|
||||||
@ -102,6 +99,7 @@ export const EditorFieldDropdownForm = ({
|
|||||||
values: value.values || [{ value: 'Option 1' }],
|
values: value.values || [{ value: 'Option 1' }],
|
||||||
required: value.required || false,
|
required: value.required || false,
|
||||||
readOnly: value.readOnly || false,
|
readOnly: value.readOnly || false,
|
||||||
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -111,7 +109,20 @@ export const EditorFieldDropdownForm = ({
|
|||||||
|
|
||||||
const addValue = () => {
|
const addValue = () => {
|
||||||
const currentValues = form.getValues('values') || [];
|
const currentValues = form.getValues('values') || [];
|
||||||
const newValues = [...currentValues, { value: 'New option' }];
|
|
||||||
|
let newValue = 'New option';
|
||||||
|
|
||||||
|
// Iterate to create a unique value
|
||||||
|
for (let i = 0; i < currentValues.length; i++) {
|
||||||
|
newValue = `New option ${i + 1}`;
|
||||||
|
if (currentValues.some((item) => item.value === `New option ${i + 1}`)) {
|
||||||
|
newValue = `New option ${i + 1}`;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValues = [...currentValues, { value: newValue }];
|
||||||
|
|
||||||
form.setValue('values', newValues);
|
form.setValue('values', newValues);
|
||||||
};
|
};
|
||||||
@ -127,6 +138,10 @@ export const EditorFieldDropdownForm = ({
|
|||||||
newValues.splice(index, 1);
|
newValues.splice(index, 1);
|
||||||
|
|
||||||
form.setValue('values', newValues);
|
form.setValue('values', newValues);
|
||||||
|
|
||||||
|
if (form.getValues('defaultValue') === newValues[index].value) {
|
||||||
|
form.setValue('defaultValue', undefined);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -140,19 +155,13 @@ export const EditorFieldDropdownForm = ({
|
|||||||
}
|
}
|
||||||
}, [formValues]);
|
}, [formValues]);
|
||||||
|
|
||||||
const { formState } = form;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log({
|
|
||||||
errors: formState.errors,
|
|
||||||
formValues,
|
|
||||||
});
|
|
||||||
}, [formState, formState.errors, formValues]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form>
|
<form>
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<EditorGenericFontSizeField formControl={form.control} />
|
||||||
|
|
||||||
|
{/* Todo: Envelopes This is buggy. */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="defaultValue"
|
name="defaultValue"
|
||||||
@ -163,20 +172,25 @@ export const EditorFieldDropdownForm = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
// Todo: Envelopes - This is buggy, removing/adding should update the default value.
|
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value}
|
value={field.value ?? '-1'}
|
||||||
onValueChange={(val) => field.onChange(val)}
|
onValueChange={(value) => field.onChange(value === '-1' ? undefined : value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||||
<SelectValue placeholder={t`Default Value`} />
|
<SelectValue placeholder={t`Default Value`} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper">
|
<SelectContent position="popper">
|
||||||
{(formValues.values || []).map((item, index) => (
|
{(formValues.values || [])
|
||||||
<SelectItem key={index} value={item.value || ''}>
|
.filter((item) => item.value)
|
||||||
{item.value}
|
.map((item, index) => (
|
||||||
</SelectItem>
|
<SelectItem key={index} value={item.value || ''}>
|
||||||
))}
|
{item.value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<SelectItem value={'-1'}>
|
||||||
|
<Trans>Default Value</Trans>
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@ -130,6 +130,12 @@ export const EditorFieldNumberForm = ({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form>
|
<form>
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<div className="flex w-full flex-row gap-x-4">
|
||||||
|
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
||||||
|
|
||||||
|
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<EditorGenericLabelField formControl={form.control} />
|
<EditorGenericLabelField formControl={form.control} />
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@ -198,12 +204,6 @@ export const EditorFieldNumberForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full flex-row gap-x-4">
|
|
||||||
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
|
||||||
|
|
||||||
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<EditorGenericRequiredField formControl={form.control} />
|
<EditorGenericRequiredField formControl={form.control} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,47 +1,62 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { PlusIcon, Trash } from 'lucide-react';
|
import { PlusIcon, Trash } from 'lucide-react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta';
|
import {
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
type TRadioFieldMeta as RadioFieldMeta,
|
||||||
|
ZRadioFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
EditorGenericFontSizeField,
|
||||||
EditorGenericReadOnlyField,
|
EditorGenericReadOnlyField,
|
||||||
EditorGenericRequiredField,
|
EditorGenericRequiredField,
|
||||||
} from './editor-field-generic-field-forms';
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
const ZRadioFieldFormSchema = z
|
const ZRadioFieldFormSchema = ZRadioFieldMeta.pick({
|
||||||
.object({
|
label: true,
|
||||||
label: z.string().optional(),
|
direction: true,
|
||||||
values: z
|
values: true,
|
||||||
.object({ id: z.number(), checked: z.boolean(), value: z.string() })
|
required: true,
|
||||||
.array()
|
readOnly: true,
|
||||||
.min(1)
|
fontSize: true,
|
||||||
.optional(),
|
}).refine(
|
||||||
required: z.boolean().optional(),
|
(data) => {
|
||||||
readOnly: z.boolean().optional(),
|
// There cannot be more than one checked option
|
||||||
})
|
if (data.values) {
|
||||||
.refine(
|
const checkedValues = data.values.filter((option) => option.checked);
|
||||||
(data) => {
|
return checkedValues.length <= 1;
|
||||||
// There cannot be more than one checked option
|
}
|
||||||
if (data.values) {
|
return true;
|
||||||
const checkedValues = data.values.filter((option) => option.checked);
|
},
|
||||||
return checkedValues.length <= 1;
|
{
|
||||||
}
|
message: 'There cannot be more than one checked option',
|
||||||
return true;
|
path: ['values'],
|
||||||
},
|
},
|
||||||
{
|
);
|
||||||
message: 'There cannot be more than one checked option',
|
|
||||||
path: ['values'],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
|
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
|
||||||
|
|
||||||
@ -53,9 +68,12 @@ export type EditorFieldRadioFormProps = {
|
|||||||
export const EditorFieldRadioForm = ({
|
export const EditorFieldRadioForm = ({
|
||||||
value = {
|
value = {
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
|
direction: 'vertical',
|
||||||
},
|
},
|
||||||
onValueChange,
|
onValueChange,
|
||||||
}: EditorFieldRadioFormProps) => {
|
}: EditorFieldRadioFormProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
const form = useForm<TRadioFieldFormSchema>({
|
const form = useForm<TRadioFieldFormSchema>({
|
||||||
resolver: zodResolver(ZRadioFieldFormSchema),
|
resolver: zodResolver(ZRadioFieldFormSchema),
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
@ -64,6 +82,8 @@ export const EditorFieldRadioForm = ({
|
|||||||
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
|
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
|
||||||
required: value.required || false,
|
required: value.required || false,
|
||||||
readOnly: value.readOnly || false,
|
readOnly: value.readOnly || false,
|
||||||
|
direction: value.direction || 'vertical',
|
||||||
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -107,7 +127,37 @@ export const EditorFieldRadioForm = ({
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form>
|
<form>
|
||||||
<fieldset className="flex flex-col gap-2 pb-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<EditorGenericFontSizeField formControl={form.control} />
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="direction"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Direction</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||||
|
<SelectValue placeholder={t`Select direction`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper">
|
||||||
|
<SelectItem value="vertical">
|
||||||
|
<Trans>Vertical</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="horizontal">
|
||||||
|
<Trans>Horizontal</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<EditorGenericRequiredField formControl={form.control} />
|
<EditorGenericRequiredField formControl={form.control} />
|
||||||
|
|
||||||
<EditorGenericReadOnlyField formControl={form.control} />
|
<EditorGenericReadOnlyField formControl={form.control} />
|
||||||
|
|||||||
@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
type TSignatureFieldMeta,
|
||||||
|
ZSignatureFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
|
import { Form } from '@documenso/ui/primitives/form/form';
|
||||||
|
|
||||||
|
import { EditorGenericFontSizeField } from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
|
const ZSignatureFieldFormSchema = ZSignatureFieldMeta.pick({
|
||||||
|
fontSize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type TSignatureFieldFormSchema = z.infer<typeof ZSignatureFieldFormSchema>;
|
||||||
|
|
||||||
|
type EditorFieldSignatureFormProps = {
|
||||||
|
value: TSignatureFieldMeta | undefined;
|
||||||
|
onValueChange: (value: TSignatureFieldMeta) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorFieldSignatureForm = ({
|
||||||
|
value = {
|
||||||
|
type: 'signature',
|
||||||
|
},
|
||||||
|
onValueChange,
|
||||||
|
}: EditorFieldSignatureFormProps) => {
|
||||||
|
const form = useForm<TSignatureFieldFormSchema>({
|
||||||
|
resolver: zodResolver(ZSignatureFieldFormSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control } = form;
|
||||||
|
|
||||||
|
const formValues = useWatch({
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||||
|
useEffect(() => {
|
||||||
|
const validatedFormValues = ZSignatureFieldFormSchema.safeParse(formValues);
|
||||||
|
|
||||||
|
if (validatedFormValues.success) {
|
||||||
|
onValueChange({
|
||||||
|
type: 'signature',
|
||||||
|
...validatedFormValues.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [formValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form>
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<EditorGenericFontSizeField formControl={form.control} />
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
<Trans>The typed signature font size</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -5,7 +5,10 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
|||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { type TTextFieldMeta as TextFieldMeta } from '@documenso/lib/types/field-meta';
|
import {
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
type TTextFieldMeta as TextFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@ -69,7 +72,7 @@ export const EditorFieldTextForm = ({
|
|||||||
placeholder: value.placeholder || '',
|
placeholder: value.placeholder || '',
|
||||||
text: value.text || '',
|
text: value.text || '',
|
||||||
characterLimit: value.characterLimit || 0,
|
characterLimit: value.characterLimit || 0,
|
||||||
fontSize: value.fontSize || 14,
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
textAlign: value.textAlign || 'left',
|
textAlign: value.textAlign || 'left',
|
||||||
required: value.required || false,
|
required: value.required || false,
|
||||||
readOnly: value.readOnly || false,
|
readOnly: value.readOnly || false,
|
||||||
@ -98,6 +101,12 @@ export const EditorFieldTextForm = ({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form>
|
<form>
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<div className="flex w-full flex-row gap-x-4">
|
||||||
|
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
||||||
|
|
||||||
|
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="label"
|
name="label"
|
||||||
@ -143,6 +152,18 @@ export const EditorFieldTextForm = ({
|
|||||||
className="h-auto"
|
className="h-auto"
|
||||||
placeholder={t`Add text to the field`}
|
placeholder={t`Add text to the field`}
|
||||||
{...field}
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
const values = form.getValues();
|
||||||
|
const characterLimit = values.characterLimit || 0;
|
||||||
|
let textValue = e.target.value;
|
||||||
|
|
||||||
|
if (characterLimit > 0 && textValue.length > characterLimit) {
|
||||||
|
textValue = textValue.slice(0, characterLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.target.value = textValue;
|
||||||
|
field.onChange(e);
|
||||||
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -166,6 +187,18 @@ export const EditorFieldTextForm = ({
|
|||||||
className="bg-background"
|
className="bg-background"
|
||||||
placeholder={t`Field character limit`}
|
placeholder={t`Field character limit`}
|
||||||
{...field}
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e);
|
||||||
|
|
||||||
|
const values = form.getValues();
|
||||||
|
const characterLimit = parseInt(e.target.value, 10) || 0;
|
||||||
|
|
||||||
|
const textValue = values.text || '';
|
||||||
|
|
||||||
|
if (characterLimit > 0 && textValue.length > characterLimit) {
|
||||||
|
form.setValue('text', textValue.slice(0, characterLimit));
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -173,12 +206,6 @@ export const EditorFieldTextForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full flex-row gap-x-4">
|
|
||||||
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
|
||||||
|
|
||||||
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<EditorGenericRequiredField formControl={form.control} />
|
<EditorGenericRequiredField formControl={form.control} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -70,6 +70,7 @@ export type SignInFormProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
initialEmail?: string;
|
initialEmail?: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
|
isMicrosoftSSOEnabled?: boolean;
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
oidcProviderLabel?: string;
|
oidcProviderLabel?: string;
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
@ -79,6 +80,7 @@ export const SignInForm = ({
|
|||||||
className,
|
className,
|
||||||
initialEmail,
|
initialEmail,
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
oidcProviderLabel,
|
oidcProviderLabel,
|
||||||
returnTo,
|
returnTo,
|
||||||
@ -90,11 +92,14 @@ export const SignInForm = ({
|
|||||||
|
|
||||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
|
||||||
|
|
||||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||||
'totp' | 'backup'
|
'totp' | 'backup'
|
||||||
>('totp');
|
>('totp');
|
||||||
|
|
||||||
|
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||||
|
|
||||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||||
|
|
||||||
const redirectPath = useMemo(() => {
|
const redirectPath = useMemo(() => {
|
||||||
@ -271,6 +276,22 @@ export const SignInForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSignInWithMicrosoftClick = async () => {
|
||||||
|
try {
|
||||||
|
await authClient.microsoft.signIn({
|
||||||
|
redirectPath,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to sign you In. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSignInWithOIDCClick = async () => {
|
const onSignInWithOIDCClick = async () => {
|
||||||
try {
|
try {
|
||||||
await authClient.oidc.signIn({
|
await authClient.oidc.signIn({
|
||||||
@ -297,6 +318,8 @@ export const SignInForm = ({
|
|||||||
if (email) {
|
if (email) {
|
||||||
form.setValue('email', email);
|
form.setValue('email', email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -363,42 +386,64 @@ export const SignInForm = ({
|
|||||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
{!isEmbeddedRedirect && (
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<>
|
||||||
<div className="bg-border h-px flex-1" />
|
{hasSocialAuthEnabled && (
|
||||||
<span className="text-muted-foreground bg-transparent">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<Trans>Or continue with</Trans>
|
<div className="bg-border h-px flex-1" />
|
||||||
</span>
|
<span className="text-muted-foreground bg-transparent">
|
||||||
<div className="bg-border h-px flex-1" />
|
<Trans>Or continue with</Trans>
|
||||||
</div>
|
</span>
|
||||||
)}
|
<div className="bg-border h-px flex-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isGoogleSSOEnabled && (
|
{isGoogleSSOEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="bg-background text-muted-foreground border"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithGoogleClick}
|
onClick={onSignInWithGoogleClick}
|
||||||
>
|
>
|
||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
Google
|
Google
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isOIDCSSOEnabled && (
|
{isMicrosoftSSOEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="bg-background text-muted-foreground border"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithOIDCClick}
|
onClick={onSignInWithMicrosoftClick}
|
||||||
>
|
>
|
||||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
<img
|
||||||
{oidcProviderLabel || 'OIDC'}
|
className="mr-2 h-4 w-4"
|
||||||
</Button>
|
alt="Microsoft Logo"
|
||||||
|
src={'/static/microsoft.svg'}
|
||||||
|
/>
|
||||||
|
Microsoft
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOIDCSSOEnabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="bg-background text-muted-foreground border"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={onSignInWithOIDCClick}
|
||||||
|
>
|
||||||
|
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||||
|
{oidcProviderLabel || 'OIDC'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -66,14 +66,18 @@ export type SignUpFormProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
initialEmail?: string;
|
initialEmail?: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
|
isMicrosoftSSOEnabled?: boolean;
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
|
returnTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignUpForm = ({
|
export const SignUpForm = ({
|
||||||
className,
|
className,
|
||||||
initialEmail,
|
initialEmail,
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
|
returnTo,
|
||||||
}: SignUpFormProps) => {
|
}: SignUpFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -84,6 +88,8 @@ export const SignUpForm = ({
|
|||||||
|
|
||||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||||
|
|
||||||
|
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||||
|
|
||||||
const form = useForm<TSignUpFormSchema>({
|
const form = useForm<TSignUpFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
name: '',
|
name: '',
|
||||||
@ -106,7 +112,7 @@ export const SignUpForm = ({
|
|||||||
signature,
|
signature,
|
||||||
});
|
});
|
||||||
|
|
||||||
await navigate(`/unverified-account`);
|
await navigate(returnTo ? returnTo : '/unverified-account');
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Registration Successful`),
|
title: _(msg`Registration Successful`),
|
||||||
@ -148,6 +154,20 @@ export const SignUpForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSignUpWithMicrosoftClick = async () => {
|
||||||
|
try {
|
||||||
|
await authClient.microsoft.signIn();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSignUpWithOIDCClick = async () => {
|
const onSignUpWithOIDCClick = async () => {
|
||||||
try {
|
try {
|
||||||
await authClient.oidc.signIn();
|
await authClient.oidc.signIn();
|
||||||
@ -227,7 +247,7 @@ export const SignUpForm = ({
|
|||||||
<fieldset
|
<fieldset
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-[550px] w-full flex-col gap-y-4',
|
'flex h-[550px] w-full flex-col gap-y-4',
|
||||||
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
|
hasSocialAuthEnabled && 'h-[650px]',
|
||||||
)}
|
)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
@ -302,7 +322,7 @@ export const SignUpForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
{hasSocialAuthEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
@ -330,6 +350,26 @@ export const SignUpForm = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isMicrosoftSSOEnabled && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant={'outline'}
|
||||||
|
className="bg-background text-muted-foreground border"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={onSignUpWithMicrosoftClick}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
alt="Microsoft Logo"
|
||||||
|
src={'/static/microsoft.svg'}
|
||||||
|
/>
|
||||||
|
<Trans>Sign Up with Microsoft</Trans>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOIDCSSOEnabled && (
|
{isOIDCSSOEnabled && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -39,6 +39,7 @@ export const SubscriptionClaimForm = ({
|
|||||||
name: subscriptionClaim.name,
|
name: subscriptionClaim.name,
|
||||||
teamCount: subscriptionClaim.teamCount,
|
teamCount: subscriptionClaim.teamCount,
|
||||||
memberCount: subscriptionClaim.memberCount,
|
memberCount: subscriptionClaim.memberCount,
|
||||||
|
envelopeItemCount: subscriptionClaim.envelopeItemCount,
|
||||||
flags: subscriptionClaim.flags,
|
flags: subscriptionClaim.flags,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -111,6 +112,30 @@ export const SubscriptionClaimForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="envelopeItemCount"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Envelope Item Count</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Feature Flags</Trans>
|
<Trans>Feature Flags</Trans>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { Theme, useTheme } from 'remix-themes';
|
import { Theme, useTheme } from 'remix-themes';
|
||||||
|
|
||||||
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
@ -63,10 +64,12 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [pages, setPages] = useState<string[]>([]);
|
const [pages, setPages] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebouncedValue(search, 200);
|
||||||
|
|
||||||
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
||||||
trpcReact.document.search.useQuery(
|
trpcReact.document.search.useQuery(
|
||||||
{
|
{
|
||||||
query: search,
|
query: debouncedSearch,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
@ -232,6 +235,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
|||||||
<Trans>No results found.</Trans>
|
<Trans>No results found.</Trans>
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!currentPage && (
|
{!currentPage && (
|
||||||
<>
|
<>
|
||||||
{documentPageLinks.length > 0 && (
|
{documentPageLinks.length > 0 && (
|
||||||
@ -239,14 +243,17 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
|||||||
<Commands push={push} pages={documentPageLinks} />
|
<Commands push={push} pages={documentPageLinks} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{templatePageLinks.length > 0 && (
|
{templatePageLinks.length > 0 && (
|
||||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
|
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
|
||||||
<Commands push={push} pages={templatePageLinks} />
|
<Commands push={push} pages={templatePageLinks} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
|
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
|
||||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
|
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
|
||||||
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
|
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
|
||||||
Change language
|
Change language
|
||||||
@ -255,6 +262,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
|||||||
Change theme
|
Change theme
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
|
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
|
||||||
<Commands push={push} pages={searchResults} />
|
<Commands push={push} pages={searchResults} />
|
||||||
|
|||||||
17
apps/remix/app/components/general/branding-logo-icon.tsx
Normal file
17
apps/remix/app/components/general/branding-logo-icon.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { SVGAttributes } from 'react';
|
||||||
|
|
||||||
|
export type LogoProps = SVGAttributes<SVGSVGElement>;
|
||||||
|
|
||||||
|
export const BrandingLogoIcon = ({ ...props }: LogoProps) => {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84 84" {...props}>
|
||||||
|
<g fill="currentColor">
|
||||||
|
<path d="M35.53 12.152c-.968.879-2.038 1.91-3.261 3.118a4.55 4.55 0 0 1-2.722.97l-4.098.079 1.194-1.194C33.883 7.885 37.502 4.265 42 4.265s8.118 3.62 15.357 10.86l1.192 1.192-3.957-.075a4.55 4.55 0 0 1-3.004-1.209l-2.373-2.194a69 69 0 0 0-.66-.61l-.128-.119h-.002a35 35 0 0 0-2.244-1.892C44.17 8.684 43 8.338 42 8.338s-2.17.346-4.18 1.88a35 35 0 0 0-2.275 1.92zM71.77 35.444a69 69 0 0 0-.608-.658l-2.196-2.374a4.55 4.55 0 0 1-1.208-3.002l-.077-3.961 1.194 1.194c7.24 7.24 10.86 10.859 10.86 15.357s-3.62 8.118-10.86 15.357l-1.194 1.194.077-3.961a4.55 4.55 0 0 1 1.209-3.002l2.195-2.373q.315-.338.609-.66l.119-.128v-.002a35 35 0 0 0 1.892-2.244c1.534-2.01 1.88-3.18 1.88-4.181s-.346-2.17-1.88-4.18a35 35 0 0 0-1.892-2.245v-.002zM48.51 71.813q.362-.33.747-.69l2.331-2.157a4.55 4.55 0 0 1 3.003-1.208l3.959-.076-1.193 1.193c-7.24 7.24-10.859 10.86-15.357 10.86s-8.118-3.62-15.357-10.86l-1.194-1.194 3.97.076a4.55 4.55 0 0 1 2.991 1.2l1.601 1.47c1.461 1.4 2.69 2.502 3.808 3.355 2.01 1.534 3.18 1.88 4.181 1.88s2.17-.346 4.18-1.88a35 35 0 0 0 2.275-1.92zM12.156 48.476q.364.4.763.825l2.115 2.287a4.55 4.55 0 0 1 1.209 3.002l.076 3.961-1.194-1.194C7.885 50.117 4.265 46.498 4.265 42s3.62-8.118 10.86-15.357l1.193-1.193-.075 3.959a4.55 4.55 0 0 1-1.21 3.004l-2.18 2.357q-.325.346-.626.676l-.117.127v.002a35 35 0 0 0-1.892 2.244C8.684 39.83 8.338 41 8.338 42s.346 2.17 1.88 4.18a35 35 0 0 0 1.92 2.275z" />
|
||||||
|
<path d="m12.138 35.543 2.896-3.13a4.55 4.55 0 0 0 1.186-2.626c.012-1.61.038-3.013.096-4.254l.003-.17.006-.005c.053-1.072.131-2.021.246-2.875.337-2.506.92-3.578 1.627-4.286s1.78-1.29 4.285-1.626c.87-.117 1.838-.196 2.935-.25l.002-.002h.06c1.285-.062 2.746-.089 4.43-.1a4.55 4.55 0 0 0 2.711-1.257l2.923-2.825h-1.688c-10.238 0-15.357 0-18.538 3.18-3.18 3.181-3.18 8.3-3.18 18.539zM12.138 48.456v1.688c0 10.239 0 15.358 3.18 18.538s8.3 3.18 18.538 3.18h16.289c10.238 0 15.357 0 18.538-3.18 3.18-3.18 3.18-8.3 3.18-18.537v-1.69l-2.897 3.133a4.55 4.55 0 0 0-1.185 2.618c-.012 1.645-.039 3.075-.1 4.335v.04h-.001a35 35 0 0 1-.25 2.936c-.337 2.506-.92 3.578-1.627 4.286s-1.78 1.29-4.285 1.626c-.855.115-1.804.194-2.876.247l-.005.005-.149.003c-1.246.058-2.658.085-4.277.097-.976.1-1.897.515-2.623 1.185l-3.132 2.897H35.573l-3.163-2.906a4.55 4.55 0 0 0-2.61-1.176 110 110 0 0 1-4.324-.1h-.056l-.002-.002a35 35 0 0 1-2.935-.25c-2.505-.336-3.578-.919-4.285-1.626-.708-.708-1.29-1.78-1.627-4.286a35 35 0 0 1-.25-2.935l-.002-.002-.001-.075c-.06-1.251-.086-2.668-.098-4.296a4.55 4.55 0 0 0-1.186-2.621zM67.781 29.794a4.55 4.55 0 0 0 1.185 2.618l2.897 3.132v-1.688c0-10.239 0-15.358-3.18-18.538s-8.3-3.18-18.538-3.18h-1.689l3.132 2.895a4.55 4.55 0 0 0 2.627 1.186c1.6.012 2.997.038 4.232.096l.247.004.008.008a34 34 0 0 1 2.816.244c2.505.337 3.578.919 4.285 1.626.708.708 1.29 1.78 1.627 4.286.117.87.196 1.839.25 2.936l.001.04c.061 1.26.088 2.69.1 4.335M38.91 23.96l-2.747 2.33a2.9 2.9 0 0 1-1.747.689l-4.597.214 2.397-2.397c4.627-4.627 6.94-6.94 9.815-6.94s5.188 2.313 9.815 6.94l2.383 2.382-4.662-.202a2.9 2.9 0 0 1-1.773-.703l-2.074-1.789c-.728-.685-1.345-1.226-1.908-1.656-1.154-.88-1.592-.9-1.78-.9-.19 0-.627.02-1.781.9l-.055.042h-.003l-.027.023c-.387.3-.8.652-1.257 1.067" />
|
||||||
|
<path d="M61.023 39.995c-.785-.992-1.911-2.163-3.542-3.803a2.9 2.9 0 0 1-.44-1.426l-.202-4.977 2.369 2.368c4.627 4.627 6.94 6.94 6.94 9.815s-2.313 5.188-6.94 9.815l-2.382 2.381.23-4.757a2.9 2.9 0 0 1 .727-1.787l1.742-1.968a28 28 0 0 0 1.387-1.569l.215-.242v-.03l.049-.062c.88-1.154.9-1.592.9-1.781 0-.19-.02-.627-.9-1.78l-.049-.064v-.024zM22.946 40.124l3.175-3.454c.45-.489.719-1.117.762-1.78l.175-2.71c.027-.86.071-1.584.144-2.216l.012-.192.013-.013.009-.065c.193-1.438.488-1.762.622-1.896s.457-.429 1.896-.622c.461-.062.974-.106 1.555-.138l3.9-.385a2.9 2.9 0 0 0 1.678-.75l3.296-3.017h-3.357c-6.543 0-9.815 0-11.847 2.033-1.732 1.732-1.988 4.363-2.026 9.15q-.009 1.246-.007 2.698v3.356" />
|
||||||
|
<path d="M22.946 43.82v3.357c0 .97 0 1.866.006 2.698.038 4.787.295 7.418 2.027 9.15 1.731 1.732 4.362 1.988 9.15 2.026q1.246.009 2.697.007h10.411q1.45.002 2.697-.007c4.788-.038 7.419-.294 9.15-2.026 2.033-2.033 2.033-5.304 2.033-11.848V43.81l-3.384 3.67a2.9 2.9 0 0 0-.69 1.29c-.006 2.38-.038 4.033-.193 5.306l-.002.068-.008.008-.012.098c-.194 1.438-.489 1.762-.623 1.896-.133.133-.457.429-1.895.622l-.099.013-.008.008-.114.007c-.724.086-1.57.133-2.602.159l-2.32.141c-.661.04-1.288.305-1.778.75l-3.538 3.212h-3.697l-3.536-3.306a2.9 2.9 0 0 0-1.69-.769q-.41 0-.79-.004c-1.906-.016-3.288-.063-4.384-.21-1.439-.194-1.762-.49-1.896-.623-.134-.134-.429-.458-.622-1.896l-.009-.065-.012-.013-.002-.027-.004-.108c-.13-1.084-.171-2.442-.185-4.283l-.02-.472a2.9 2.9 0 0 0-.755-1.833zM57.01 32.35l.19 2.586c.049.652.315 1.27.757 1.751l3.16 3.447v-3.367c0-6.544 0-9.815-2.032-11.848s-5.305-2.033-11.848-2.033H43.85l3.391 3.09c.475.432 1.08.696 1.721.748l3.933.322q.562.033 1.045.085l.29.024.013.012.066.01c1.438.192 1.762.488 1.895.621.134.134.43.458.623 1.896.098.733.152 1.595.182 2.655" />
|
||||||
|
<path d="m27.226 54.158-.013-.013.002.027.012.013zM29.849 56.78l4.289.199c-1.852-.015-3.208-.06-4.29-.198M27.044 49.476a3 3 0 0 0-.08-.57 3 3 0 0 1 .04.376l.02.472c.014 1.84.056 3.2.185 4.283l.004.108.013.013zM17.915 41.972c0 2.45 1.679 4.491 5.038 7.903q-.009-1.246-.007-2.698v-3.344l-.007-.008v-.005l-.052-.068c-.88-1.153-.9-1.59-.9-1.78s.02-.627.9-1.78l.059-.077v-3.348q-.001-1.452.006-2.698c-3.358 3.412-5.037 5.454-5.037 7.903M40.25 61.116l-.048-.037h-.01l-.022-.021h-3.344q-1.45.002-2.697-.007c3.412 3.358 5.453 5.038 7.902 5.038 2.45 0 4.491-1.68 7.903-5.038q-1.246.009-2.697.007h-3.35l-.075.058c-1.154.88-1.592.9-1.78.9-.19 0-.627-.02-1.781-.9" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -89,7 +89,10 @@ export const DirectTemplatePageView = ({
|
|||||||
setStep('sign');
|
setStep('sign');
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => {
|
const onSignDirectTemplateSubmit = async (
|
||||||
|
fields: DirectTemplateLocalField[],
|
||||||
|
nextSigner?: { name: string; email: string },
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
|
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
|
||||||
|
|
||||||
@ -98,6 +101,7 @@ export const DirectTemplatePageView = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { token } = await createDocumentFromDirectTemplate({
|
const { token } = await createDocumentFromDirectTemplate({
|
||||||
|
nextSigner,
|
||||||
directTemplateToken,
|
directTemplateToken,
|
||||||
directTemplateExternalId,
|
directTemplateExternalId,
|
||||||
directRecipientName: fullName,
|
directRecipientName: fullName,
|
||||||
@ -149,7 +153,9 @@ export const DirectTemplatePageView = ({
|
|||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
key={template.id}
|
key={template.id}
|
||||||
documentData={template.templateDocumentData}
|
envelopeItem={template.envelopeItems[0]}
|
||||||
|
token={directTemplateRecipient.token}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -55,10 +55,13 @@ import { DocumentSigningRecipientProvider } from '../document-signing/document-s
|
|||||||
|
|
||||||
export type DirectTemplateSigningFormProps = {
|
export type DirectTemplateSigningFormProps = {
|
||||||
flowStep: DocumentFlowStep;
|
flowStep: DocumentFlowStep;
|
||||||
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
|
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'>;
|
||||||
directRecipientFields: Field[];
|
directRecipientFields: Field[];
|
||||||
template: Omit<TTemplate, 'user'>;
|
template: Omit<TTemplate, 'user'>;
|
||||||
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
|
onSubmit: (
|
||||||
|
_data: DirectTemplateLocalField[],
|
||||||
|
_nextSigner?: { name: string; email: string },
|
||||||
|
) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DirectTemplateLocalField = Field & {
|
export type DirectTemplateLocalField = Field & {
|
||||||
@ -149,7 +152,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (nextSigner?: { name: string; email: string }) => {
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
|
|
||||||
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
||||||
@ -161,7 +164,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSubmit(localFields);
|
await onSubmit(localFields, nextSigner);
|
||||||
} catch {
|
} catch {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -218,6 +221,30 @@ export const DirectTemplateSigningForm = ({
|
|||||||
setLocalFields(updatedFields);
|
setLocalFields(updatedFields);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const nextRecipient = useMemo(() => {
|
||||||
|
if (
|
||||||
|
!template.templateMeta?.signingOrder ||
|
||||||
|
template.templateMeta.signingOrder !== 'SEQUENTIAL' ||
|
||||||
|
!template.templateMeta.allowDictateNextSigner
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedRecipients = template.recipients.sort((a, b) => {
|
||||||
|
// Sort by signingOrder first (nulls last), then by id
|
||||||
|
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
|
||||||
|
if (a.signingOrder === null) return 1;
|
||||||
|
if (b.signingOrder === null) return -1;
|
||||||
|
if (a.signingOrder === b.signingOrder) return a.id - b.id;
|
||||||
|
return a.signingOrder - b.signingOrder;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id);
|
||||||
|
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
||||||
|
? sortedRecipients[currentIndex + 1]
|
||||||
|
: undefined;
|
||||||
|
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningRecipientProvider recipient={directRecipient}>
|
<DocumentSigningRecipientProvider recipient={directRecipient}>
|
||||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||||
@ -417,11 +444,15 @@ export const DirectTemplateSigningForm = ({
|
|||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={async () => handleSubmit()}
|
onSignatureComplete={async (nextSigner) => handleSubmit(nextSigner)}
|
||||||
documentTitle={template.title}
|
documentTitle={template.title}
|
||||||
fields={localFields}
|
fields={localFields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
recipient={directRecipient}
|
recipient={directRecipient}
|
||||||
|
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DocumentFlowFormContainerFooter>
|
</DocumentFlowFormContainerFooter>
|
||||||
|
|||||||
@ -0,0 +1,83 @@
|
|||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { ExternalLink, PaperclipIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||||
|
|
||||||
|
export type DocumentSigningAttachmentsPopoverProps = {
|
||||||
|
envelopeId: string;
|
||||||
|
token: string;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentSigningAttachmentsPopover = ({
|
||||||
|
envelopeId,
|
||||||
|
token,
|
||||||
|
trigger,
|
||||||
|
}: DocumentSigningAttachmentsPopoverProps) => {
|
||||||
|
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
|
||||||
|
envelopeId,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attachments || attachments.data.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<PaperclipIcon className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>Attachments</Trans>{' '}
|
||||||
|
{attachments && attachments.data.length > 0 && (
|
||||||
|
<span className="ml-1">({attachments.data.length})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent className="w-96" align="start">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">
|
||||||
|
<Trans>Attachments</Trans>
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
<Trans>Documents and resources related to this envelope.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attachments?.data.map((attachment) => (
|
||||||
|
<a
|
||||||
|
key={attachment.id}
|
||||||
|
href={attachment.data}
|
||||||
|
title={attachment.data}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="border-border hover:bg-muted/50 group flex items-center justify-between rounded-md border px-3 py-2.5 transition duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 items-center gap-2.5">
|
||||||
|
<div className="bg-muted rounded p-2">
|
||||||
|
<PaperclipIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-muted-foreground hover:text-foreground block truncate text-sm underline">
|
||||||
|
{attachment.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExternalLink className="h-4 w-4 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -22,7 +22,7 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
actionVerb = 'sign',
|
actionVerb = 'sign',
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DocumentSigningAuthAccountProps) => {
|
}: DocumentSigningAuthAccountProps) => {
|
||||||
const { recipient } = useRequiredDocumentSigningAuthContext();
|
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
@ -34,8 +34,10 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
try {
|
try {
|
||||||
setIsSigningOut(true);
|
setIsSigningOut(true);
|
||||||
|
|
||||||
|
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
redirectPath: `/signin#email=${email}`,
|
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setIsSigningOut(false);
|
setIsSigningOut(false);
|
||||||
@ -55,16 +57,28 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||||
<span>
|
<span>
|
||||||
<Trans>
|
{isDirectTemplate ? (
|
||||||
To mark this document as viewed, you need to be logged in as{' '}
|
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
|
||||||
<strong>{recipient.email}</strong>
|
) : (
|
||||||
</Trans>
|
<Trans>
|
||||||
|
To mark this document as viewed, you need to be logged in as{' '}
|
||||||
|
<strong>{recipient.email}</strong>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
{/* Todo: Translate */}
|
{isDirectTemplate ? (
|
||||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
|
<Trans>
|
||||||
in as <strong>{recipient.email}</strong>
|
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||||
|
logged in.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||||
|
logged in as <strong>{recipient.email}</strong>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|||||||
@ -47,7 +47,8 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onReauthFormSubmit,
|
onReauthFormSubmit,
|
||||||
}: DocumentSigningAuthDialogProps) => {
|
}: DocumentSigningAuthDialogProps) => {
|
||||||
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
|
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
|
||||||
|
useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
// Filter out EXPLICIT_NONE from available auth types for the chooser
|
// Filter out EXPLICIT_NONE from available auth types for the chooser
|
||||||
const validAuthTypes = availableAuthTypes.filter(
|
const validAuthTypes = availableAuthTypes.filter(
|
||||||
@ -168,7 +169,11 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
match({ documentAuthType: selectedAuthType, user })
|
match({ documentAuthType: selectedAuthType, user })
|
||||||
.with(
|
.with(
|
||||||
{ documentAuthType: DocumentAuth.ACCOUNT },
|
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||||
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
|
{
|
||||||
|
user: P.when(
|
||||||
|
(user) => !user || (user.email !== recipient.email && !isDirectTemplate),
|
||||||
|
),
|
||||||
|
}, // Assume all current auth methods requires them to be logged in.
|
||||||
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
|
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
|
||||||
)
|
)
|
||||||
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DocumentSigningAuthPageViewProps = {
|
export type DocumentSigningAuthPageViewProps = {
|
||||||
email: string;
|
email?: string;
|
||||||
emailHasAccount?: boolean;
|
emailHasAccount?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -22,12 +22,18 @@ export const DocumentSigningAuthPageView = ({
|
|||||||
|
|
||||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||||
|
|
||||||
const handleChangeAccount = async (email: string) => {
|
const handleChangeAccount = async (email?: string) => {
|
||||||
try {
|
try {
|
||||||
setIsSigningOut(true);
|
setIsSigningOut(true);
|
||||||
|
|
||||||
|
let redirectPath = '/signin';
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
|
||||||
|
}
|
||||||
|
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
redirectPath: emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`,
|
redirectPath,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
@ -49,9 +55,13 @@ export const DocumentSigningAuthPageView = ({
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
<Trans>
|
{email ? (
|
||||||
You need to be logged in as <strong>{email}</strong> to view this page.
|
<Trans>
|
||||||
</Trans>
|
You need to be logged in as <strong>{email}</strong> to view this page.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>You need to be logged in to view this page.</Trans>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -24,7 +24,10 @@ type PasskeyData = {
|
|||||||
isError: boolean;
|
isError: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SigningAuthRecipient = Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
|
type SigningAuthRecipient = Pick<
|
||||||
|
Recipient,
|
||||||
|
'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'
|
||||||
|
>;
|
||||||
|
|
||||||
export type DocumentSigningAuthContextValue = {
|
export type DocumentSigningAuthContextValue = {
|
||||||
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
||||||
@ -37,6 +40,7 @@ export type DocumentSigningAuthContextValue = {
|
|||||||
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
||||||
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
||||||
isAuthRedirectRequired: boolean;
|
isAuthRedirectRequired: boolean;
|
||||||
|
isDirectTemplate?: boolean;
|
||||||
isCurrentlyAuthenticating: boolean;
|
isCurrentlyAuthenticating: boolean;
|
||||||
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
||||||
passkeyData: PasskeyData;
|
passkeyData: PasskeyData;
|
||||||
@ -65,6 +69,7 @@ export const useRequiredDocumentSigningAuthContext = () => {
|
|||||||
export interface DocumentSigningAuthProviderProps {
|
export interface DocumentSigningAuthProviderProps {
|
||||||
documentAuthOptions: Envelope['authOptions'];
|
documentAuthOptions: Envelope['authOptions'];
|
||||||
recipient: SigningAuthRecipient;
|
recipient: SigningAuthRecipient;
|
||||||
|
isDirectTemplate?: boolean;
|
||||||
user?: SessionUser | null;
|
user?: SessionUser | null;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
@ -72,6 +77,7 @@ export interface DocumentSigningAuthProviderProps {
|
|||||||
export const DocumentSigningAuthProvider = ({
|
export const DocumentSigningAuthProvider = ({
|
||||||
documentAuthOptions: initialDocumentAuthOptions,
|
documentAuthOptions: initialDocumentAuthOptions,
|
||||||
recipient: initialRecipient,
|
recipient: initialRecipient,
|
||||||
|
isDirectTemplate = false,
|
||||||
user,
|
user,
|
||||||
children,
|
children,
|
||||||
}: DocumentSigningAuthProviderProps) => {
|
}: DocumentSigningAuthProviderProps) => {
|
||||||
@ -201,6 +207,7 @@ export const DocumentSigningAuthProvider = ({
|
|||||||
derivedRecipientAccessAuth,
|
derivedRecipientAccessAuth,
|
||||||
derivedRecipientActionAuth,
|
derivedRecipientActionAuth,
|
||||||
isAuthRedirectRequired,
|
isAuthRedirectRequired,
|
||||||
|
isDirectTemplate,
|
||||||
isCurrentlyAuthenticating,
|
isCurrentlyAuthenticating,
|
||||||
setIsCurrentlyAuthenticating,
|
setIsCurrentlyAuthenticating,
|
||||||
passkeyData,
|
passkeyData,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import type { Field, Recipient } from '@prisma/client';
|
import type { Field, Recipient } from '@prisma/client';
|
||||||
import { RecipientRole } from '@prisma/client';
|
import { RecipientRole } from '@prisma/client';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@ -18,7 +18,9 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
@ -32,6 +34,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
@ -45,6 +48,7 @@ export type DocumentSigningCompleteDialogProps = {
|
|||||||
onSignatureComplete: (
|
onSignatureComplete: (
|
||||||
nextSigner?: { name: string; email: string },
|
nextSigner?: { name: string; email: string },
|
||||||
accessAuthOptions?: TRecipientAccessAuth,
|
accessAuthOptions?: TRecipientAccessAuth,
|
||||||
|
directRecipient?: { name: string; email: string },
|
||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
|
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@ -53,6 +57,12 @@ export type DocumentSigningCompleteDialogProps = {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
};
|
};
|
||||||
|
directTemplatePayload?: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
buttonSize?: 'sm' | 'lg';
|
||||||
|
position?: 'start' | 'end' | 'center';
|
||||||
};
|
};
|
||||||
|
|
||||||
const ZNextSignerFormSchema = z.object({
|
const ZNextSignerFormSchema = z.object({
|
||||||
@ -63,6 +73,13 @@ const ZNextSignerFormSchema = z.object({
|
|||||||
|
|
||||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||||
|
|
||||||
|
const ZDirectRecipientFormSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TDirectRecipientFormSchema = z.infer<typeof ZDirectRecipientFormSchema>;
|
||||||
|
|
||||||
export const DocumentSigningCompleteDialog = ({
|
export const DocumentSigningCompleteDialog = ({
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
documentTitle,
|
documentTitle,
|
||||||
@ -72,15 +89,21 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
recipient,
|
recipient,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
allowDictateNextSigner = false,
|
allowDictateNextSigner = false,
|
||||||
|
directTemplatePayload,
|
||||||
defaultNextSigner,
|
defaultNextSigner,
|
||||||
|
buttonSize = 'lg',
|
||||||
|
position,
|
||||||
}: DocumentSigningCompleteDialogProps) => {
|
}: DocumentSigningCompleteDialogProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
|
||||||
|
|
||||||
const [showTwoFactorForm, setShowTwoFactorForm] = useState(false);
|
const [showTwoFactorForm, setShowTwoFactorForm] = useState(false);
|
||||||
const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null);
|
const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth, user } = useRequiredDocumentSigningAuthContext();
|
const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
|
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
const form = useForm<TNextSignerFormSchema>({
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||||
@ -90,6 +113,14 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
|
||||||
|
resolver: zodResolver(ZDirectRecipientFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: directTemplatePayload?.name ?? '',
|
||||||
|
email: directTemplatePayload?.email ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
||||||
|
|
||||||
const completionRequires2FA = useMemo(
|
const completionRequires2FA = useMemo(
|
||||||
@ -109,12 +140,23 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsEditingNextSigner(false);
|
|
||||||
setShowDialog(open);
|
setShowDialog(open);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||||
try {
|
try {
|
||||||
|
let directRecipient: { name: string; email: string } | undefined;
|
||||||
|
|
||||||
|
if (directTemplatePayload && !directTemplatePayload.email) {
|
||||||
|
const isFormValid = await directRecipientForm.trigger();
|
||||||
|
|
||||||
|
if (!isFormValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
directRecipient = directRecipientForm.getValues();
|
||||||
|
}
|
||||||
|
|
||||||
// Check if 2FA is required
|
// Check if 2FA is required
|
||||||
if (completionRequires2FA && !data.accessAuthOptions) {
|
if (completionRequires2FA && !data.accessAuthOptions) {
|
||||||
setShowTwoFactorForm(true);
|
setShowTwoFactorForm(true);
|
||||||
@ -126,7 +168,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
? { name: data.name, email: data.email }
|
? { name: data.name, email: data.email }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
await onSignatureComplete(nextSigner, data.accessAuthOptions);
|
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = AppError.parseError(error);
|
const err = AppError.parseError(error);
|
||||||
|
|
||||||
@ -152,21 +194,19 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
void form.handleSubmit(onFormSubmit)();
|
void form.handleSubmit(onFormSubmit)();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size={buttonSize}
|
||||||
onClick={fieldsValidated}
|
onClick={fieldsValidated}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{match({ isComplete, role: recipient.role })
|
{match({ isComplete, role: recipient.role })
|
||||||
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
|
.with({ isComplete: false }, () => <Trans>Next Field</Trans>)
|
||||||
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
|
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
|
||||||
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
|
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
|
||||||
<Trans>Mark as viewed</Trans>
|
<Trans>Mark as viewed</Trans>
|
||||||
@ -176,106 +216,103 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent position={position}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Are you sure?</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
|
{match(recipient.role)
|
||||||
|
.with(RecipientRole.VIEWER, () => (
|
||||||
|
<span className="inline-flex flex-wrap">
|
||||||
|
<Trans>You are about to complete viewing the following document</Trans>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.SIGNER, () => (
|
||||||
|
<span className="inline-flex flex-wrap">
|
||||||
|
<Trans>You are about to complete signing the following document</Trans>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.APPROVER, () => (
|
||||||
|
<span className="inline-flex flex-wrap">
|
||||||
|
<Trans>You are about to complete approving the following document</Trans>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.ASSISTANT, () => (
|
||||||
|
<span className="inline-flex flex-wrap">
|
||||||
|
<Trans>You are about to complete assisting the following document</Trans>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.CC, () => null)
|
||||||
|
.exhaustive()}
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!showTwoFactorForm && (
|
{!showTwoFactorForm && (
|
||||||
<Form {...form}>
|
<>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
{directTemplatePayload && !directTemplatePayload.email && (
|
||||||
<DialogTitle>
|
<Form {...directRecipientForm}>
|
||||||
<div className="text-foreground text-xl font-semibold">
|
<div className="mb-4 flex flex-col gap-4">
|
||||||
{match(recipient.role)
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
<FormField
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
control={directRecipientForm.control}
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
name="name"
|
||||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
render={({ field }) => (
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
<FormItem className="flex-1">
|
||||||
.exhaustive()}
|
<FormLabel>
|
||||||
|
<Trans>Your Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="mt-2"
|
||||||
|
placeholder={t`Enter your name`}
|
||||||
|
disabled={isNameLocked}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={directRecipientForm.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Your Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="email"
|
||||||
|
className="mt-2"
|
||||||
|
placeholder={t`Enter your email`}
|
||||||
|
disabled={!!field.value && isEmailLocked}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogTitle>
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
<Form {...form}>
|
||||||
{match(recipient.role)
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
.with(RecipientRole.VIEWER, () => (
|
{allowDictateNextSigner && defaultNextSigner && (
|
||||||
<span>
|
<div className="mb-4 flex flex-col gap-4">
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete viewing "
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
{documentTitle}
|
|
||||||
</span>
|
|
||||||
".
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
.with(RecipientRole.SIGNER, () => (
|
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete signing "
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
{documentTitle}
|
|
||||||
</span>
|
|
||||||
".
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
.with(RecipientRole.APPROVER, () => (
|
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete approving{' '}
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
"{documentTitle}"
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete viewing "
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
{documentTitle}
|
|
||||||
</span>
|
|
||||||
".
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{allowDictateNextSigner && (
|
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
|
||||||
{!isEditingNextSigner && (
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
The next recipient to sign this document will be{' '}
|
|
||||||
<span className="font-semibold">{form.watch('name')}</span> (
|
|
||||||
<span className="font-semibold">{form.watch('email')}</span>).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="mt-2"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
|
||||||
>
|
|
||||||
<Trans>Update Recipient</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEditingNextSigner && (
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@ -283,13 +320,13 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Name</Trans>
|
<Trans>Next Recipient Name</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
placeholder="Enter the next signer's name"
|
placeholder={t`Enter the next signer's name`}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@ -304,14 +341,14 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Email</Trans>
|
<Trans>Next Recipient Email</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
type="email"
|
type="email"
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
placeholder="Enter the next signer's email"
|
placeholder={t`Enter the next signer's email`}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -319,17 +356,14 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<DocumentSigningDisclosure className="mt-4" />
|
<DocumentSigningDisclosure />
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<DialogFooter className="mt-4">
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setShowDialog(false)}
|
onClick={() => setShowDialog(false)}
|
||||||
disabled={form.formState.isSubmitting}
|
disabled={form.formState.isSubmitting}
|
||||||
@ -339,8 +373,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex-1"
|
disabled={!isComplete}
|
||||||
disabled={!isComplete || !isNextSignerValid}
|
|
||||||
loading={form.formState.isSubmitting}
|
loading={form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
{match(recipient.role)
|
{match(recipient.role)
|
||||||
@ -351,11 +384,11 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</DialogFooter>
|
</form>
|
||||||
</fieldset>
|
</Form>
|
||||||
</form>
|
</fieldset>
|
||||||
</Form>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showTwoFactorForm && (
|
{showTwoFactorForm && (
|
||||||
|
|||||||
@ -0,0 +1,135 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
|
|
||||||
|
import { BrandingLogo } from '../branding-logo';
|
||||||
|
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||||
|
import { EnvelopeSignerCompleteDialog } from '../envelope-signing/envelope-signing-complete-dialog';
|
||||||
|
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
||||||
|
|
||||||
|
export const DocumentSigningMobileWidget = () => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const { hidePoweredBy = true } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
|
const { recipientFieldsRemaining, recipient, requiredRecipientFields } =
|
||||||
|
useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre open the widget for assistants to let them know it's there.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||||
|
setIsExpanded(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 flex justify-center px-2 pb-2 sm:px-4 sm:pb-6">
|
||||||
|
<div className="pointer-events-auto w-full max-w-[760px]">
|
||||||
|
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-2xl">
|
||||||
|
{/* Main Header Bar */}
|
||||||
|
<div className="flex items-center justify-between gap-4 p-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{recipient.role !== RecipientRole.VIEWER && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="flex h-8 w-8 items-center justify-center"
|
||||||
|
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<LucideChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<LucideChevronUp className="text-muted-foreground h-5 w-5 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-foreground text-lg font-semibold">
|
||||||
|
{match(recipient.role)
|
||||||
|
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||||
|
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||||
|
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||||
|
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||||
|
.otherwise(() => null)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground -mt-0.5 text-sm">
|
||||||
|
{recipientFieldsRemaining.length === 0 ? (
|
||||||
|
match(recipient.role)
|
||||||
|
.with(RecipientRole.VIEWER, () => (
|
||||||
|
<Trans>Please mark as viewed to complete</Trans>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.SIGNER, () => (
|
||||||
|
<Trans>Please complete the document once reviewed</Trans>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.APPROVER, () => (
|
||||||
|
<Trans>Please complete the document once reviewed</Trans>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.ASSISTANT, () => (
|
||||||
|
<Trans>Please complete the document once reviewed</Trans>
|
||||||
|
))
|
||||||
|
.otherwise(() => null)
|
||||||
|
) : (
|
||||||
|
<Plural
|
||||||
|
value={recipientFieldsRemaining.length}
|
||||||
|
one="1 Field Remaining"
|
||||||
|
other="# Fields Remaining"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<EnvelopeSignerCompleteDialog />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{recipient.role !== RecipientRole.VIEWER &&
|
||||||
|
recipient.role !== RecipientRole.ASSISTANT && (
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<div className="bg-muted relative h-[4px] rounded-md">
|
||||||
|
<motion.div
|
||||||
|
layout="size"
|
||||||
|
layoutId="document-signing-mobile-widget-progress-bar"
|
||||||
|
className="bg-documenso absolute inset-y-0 left-0"
|
||||||
|
style={{
|
||||||
|
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expandable Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-border animate-in slide-in-from-bottom-2 border-t p-4 duration-200">
|
||||||
|
<EnvelopeSignerForm />
|
||||||
|
|
||||||
|
{!hidePoweredBy && (
|
||||||
|
<div className="bg-primary text-primary-foreground mt-2 inline-block rounded px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:hidden">
|
||||||
|
<span>Powered by</span>
|
||||||
|
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -32,6 +32,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
|
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
|
||||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||||
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
||||||
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
|
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
|
||||||
@ -231,14 +232,25 @@ export const DocumentSigningPageViewV1 = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
|
<div className="flex items-center gap-x-4">
|
||||||
|
<DocumentSigningAttachmentsPopover
|
||||||
|
envelopeId={document.envelopeId}
|
||||||
|
token={recipient.token}
|
||||||
|
/>
|
||||||
|
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
|
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
<PDFViewer
|
||||||
|
key={document.envelopeItems[0].id}
|
||||||
|
envelopeItem={document.envelopeItems[0]}
|
||||||
|
token={recipient.token}
|
||||||
|
version="signed"
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,16 +1,20 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy, useMemo } from 'react';
|
||||||
|
|
||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon } from 'lucide-react';
|
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon, PaperclipIcon } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
|
|
||||||
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
|
import { SignFieldCheckboxDialog } from '~/components/dialogs/sign-field-checkbox-dialog';
|
||||||
import { SignFieldDropdownDialog } from '~/components/dialogs/sign-field-dropdown-dialog';
|
import { SignFieldDropdownDialog } from '~/components/dialogs/sign-field-dropdown-dialog';
|
||||||
import { SignFieldEmailDialog } from '~/components/dialogs/sign-field-email-dialog';
|
import { SignFieldEmailDialog } from '~/components/dialogs/sign-field-email-dialog';
|
||||||
import { SignFieldInitialsDialog } from '~/components/dialogs/sign-field-initials-dialog';
|
import { SignFieldInitialsDialog } from '~/components/dialogs/sign-field-initials-dialog';
|
||||||
@ -18,10 +22,15 @@ import { SignFieldNameDialog } from '~/components/dialogs/sign-field-name-dialog
|
|||||||
import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-dialog';
|
import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-dialog';
|
||||||
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
|
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
|
||||||
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
|
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
|
|
||||||
|
import { BrandingLogo } from '../branding-logo';
|
||||||
|
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
|
||||||
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
|
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
|
||||||
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||||
import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header';
|
import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header';
|
||||||
|
import { DocumentSigningMobileWidget } from './document-signing-mobile-widget';
|
||||||
|
import { DocumentSigningRejectDialog } from './document-signing-reject-dialog';
|
||||||
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
||||||
|
|
||||||
const EnvelopeSignerPageRenderer = lazy(
|
const EnvelopeSignerPageRenderer = lazy(
|
||||||
@ -31,11 +40,38 @@ const EnvelopeSignerPageRenderer = lazy(
|
|||||||
export const DocumentSigningPageViewV2 = () => {
|
export const DocumentSigningPageViewV2 = () => {
|
||||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const { envelope, recipientFields, recipientFieldsRemaining, showPendingFieldTooltip } =
|
const {
|
||||||
useRequiredEnvelopeSigningContext();
|
isDirectTemplate,
|
||||||
|
envelope,
|
||||||
|
recipient,
|
||||||
|
recipientFields,
|
||||||
|
recipientFieldsRemaining,
|
||||||
|
requiredRecipientFields,
|
||||||
|
selectedAssistantRecipientFields,
|
||||||
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isEmbed = false,
|
||||||
|
allowDocumentRejection = true,
|
||||||
|
hidePoweredBy = true,
|
||||||
|
onDocumentRejected,
|
||||||
|
} = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total remaining fields remaining for the current recipient or selected assistant recipient.
|
||||||
|
*
|
||||||
|
* Includes both optional and required fields.
|
||||||
|
*/
|
||||||
|
const remainingFields = useMemo(() => {
|
||||||
|
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||||
|
return selectedAssistantRecipientFields.filter((field) => !field.inserted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipientFields.filter((field) => !field.inserted);
|
||||||
|
}, [recipientFieldsRemaining, selectedAssistantRecipientFields, currentEnvelopeItem]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen bg-gray-50">
|
<div className="dark:bg-background min-h-screen w-screen bg-gray-50">
|
||||||
<SignFieldEmailDialog.Root />
|
<SignFieldEmailDialog.Root />
|
||||||
<SignFieldTextDialog.Root />
|
<SignFieldTextDialog.Root />
|
||||||
<SignFieldNumberDialog.Root />
|
<SignFieldNumberDialog.Root />
|
||||||
@ -43,19 +79,29 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
<SignFieldInitialsDialog.Root />
|
<SignFieldInitialsDialog.Root />
|
||||||
<SignFieldDropdownDialog.Root />
|
<SignFieldDropdownDialog.Root />
|
||||||
<SignFieldSignatureDialog.Root />
|
<SignFieldSignatureDialog.Root />
|
||||||
|
<SignFieldCheckboxDialog.Root />
|
||||||
|
|
||||||
<EnvelopeSignerHeader />
|
<EnvelopeSignerHeader />
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex h-[calc(100vh-73px)] w-screen">
|
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||||
{/* Left Section - Step Navigation */}
|
{/* Left Section - Step Navigation */}
|
||||||
<div className="hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-gray-200 bg-white py-4 lg:flex">
|
<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="px-4">
|
<div className="px-4">
|
||||||
<h3 className="flex items-end justify-between text-sm font-semibold text-gray-900">
|
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
|
||||||
<Trans>Sign Document</Trans>
|
{match(recipient.role)
|
||||||
|
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||||
|
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||||
|
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||||
|
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||||
|
.otherwise(() => null)}
|
||||||
|
|
||||||
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
|
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
|
||||||
<Trans>{recipientFieldsRemaining.length} fields remaining</Trans>
|
<Plural
|
||||||
|
value={recipientFieldsRemaining.length}
|
||||||
|
one="1 Field Remaining"
|
||||||
|
other="# Fields Remaining"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -65,12 +111,12 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
layoutId="document-flow-container-step"
|
layoutId="document-flow-container-step"
|
||||||
className="bg-documenso absolute inset-y-0 left-0"
|
className="bg-documenso absolute inset-y-0 left-0"
|
||||||
style={{
|
style={{
|
||||||
width: `${(100 / recipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 space-y-3">
|
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
|
||||||
<EnvelopeSignerForm />
|
<EnvelopeSignerForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -78,84 +124,113 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
<Separator className="my-6" />
|
<Separator className="my-6" />
|
||||||
|
|
||||||
{/* Quick Actions. */}
|
{/* Quick Actions. */}
|
||||||
<div className="space-y-3 px-4">
|
{!isDirectTemplate && (
|
||||||
<h4 className="text-sm font-semibold text-gray-900">
|
<div className="embed--Actions space-y-3 px-4">
|
||||||
<Trans>Actions</Trans>
|
<h4 className="text-foreground text-sm font-semibold">
|
||||||
</h4>
|
<Trans>Actions</Trans>
|
||||||
|
</h4>
|
||||||
|
|
||||||
{/* Todo: Allow selecting which document to download and/or the original */}
|
<DocumentSigningAttachmentsPopover
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
envelopeId={envelope.id}
|
||||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
token={recipient.token}
|
||||||
<Trans>Download Original</Trans>
|
trigger={
|
||||||
</Button>
|
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||||
|
<PaperclipIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Attachments</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Todo: Envelopes */}
|
<EnvelopeDownloadDialog
|
||||||
<Button
|
envelopeId={envelope.id}
|
||||||
variant="ghost"
|
envelopeStatus={envelope.status}
|
||||||
size="sm"
|
envelopeItems={envelope.envelopeItems}
|
||||||
className="hover:text-destructive w-full justify-start"
|
token={recipient.token}
|
||||||
>
|
trigger={
|
||||||
<BanIcon className="mr-2 h-4 w-4" />
|
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||||
<Trans>Reject Document</Trans>
|
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
<Trans>Download PDF</Trans>
|
||||||
</div>
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Footer of left sidebar. */}
|
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
|
||||||
<div className="mt-auto px-4">
|
<DocumentSigningRejectDialog
|
||||||
<Button asChild variant="ghost" className="w-full justify-start">
|
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
<Link to="/">
|
token={recipient.token}
|
||||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
onRejected={
|
||||||
<Trans>Return</Trans>
|
onDocumentRejected &&
|
||||||
</Link>
|
((reason) =>
|
||||||
</Button>
|
onDocumentRejected({
|
||||||
|
token: recipient.token,
|
||||||
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
reason,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="hover:text-destructive w-full justify-start"
|
||||||
|
>
|
||||||
|
<BanIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Reject Document</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="embed--DocumentWidgetFooter">
|
||||||
|
{/* Footer of left sidebar. */}
|
||||||
|
{!isEmbed && (
|
||||||
|
<div className="mt-auto px-4">
|
||||||
|
<Button asChild variant="ghost" className="w-full justify-start">
|
||||||
|
<Link to="/">
|
||||||
|
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Return</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content - Changes based on current step */}
|
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{/* Horizontal envelope item selector */}
|
{/* Horizontal envelope item selector */}
|
||||||
<div className="flex h-fit space-x-2 overflow-x-auto p-4">
|
{envelopeItems.length > 1 && (
|
||||||
{envelopeItems.map((doc, i) => (
|
<div className="flex h-fit space-x-2 overflow-x-auto p-2 pt-4 sm:p-4">
|
||||||
<EnvelopeItemSelector
|
{envelopeItems.map((doc, i) => (
|
||||||
key={doc.id}
|
<EnvelopeItemSelector
|
||||||
number={i + 1}
|
key={doc.id}
|
||||||
primaryText={doc.title}
|
number={i + 1}
|
||||||
secondaryText={
|
primaryText={doc.title}
|
||||||
<Plural
|
secondaryText={
|
||||||
one="1 Field"
|
<Plural
|
||||||
other="# Fields"
|
one="1 Field"
|
||||||
value={
|
other="# Fields"
|
||||||
recipientFieldsRemaining.filter((field) => field.envelopeItemId === doc.id)
|
value={
|
||||||
.length
|
remainingFields.filter((field) => field.envelopeItemId === doc.id).length
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
isSelected={currentEnvelopeItem?.id === doc.id}
|
isSelected={currentEnvelopeItem?.id === doc.id}
|
||||||
buttonProps={{ onClick: () => setCurrentEnvelopeItem(doc.id) }}
|
buttonProps={{ onClick: () => setCurrentEnvelopeItem(doc.id) }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Document View */}
|
{/* Document View */}
|
||||||
<div className="mt-4 flex justify-center p-4">
|
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
||||||
{currentEnvelopeItem &&
|
|
||||||
showPendingFieldTooltip &&
|
|
||||||
recipientFieldsRemaining.length > 0 &&
|
|
||||||
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id && (
|
|
||||||
<FieldToolTip
|
|
||||||
key={recipientFieldsRemaining[0].id}
|
|
||||||
field={recipientFieldsRemaining[0]}
|
|
||||||
color="warning"
|
|
||||||
>
|
|
||||||
<Trans>Click to insert field</Trans>
|
|
||||||
</FieldToolTip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentEnvelopeItem ? (
|
{currentEnvelopeItem ? (
|
||||||
<PDFViewerKonvaLazy
|
<PDFViewerKonvaLazy
|
||||||
|
renderer="signing"
|
||||||
key={currentEnvelopeItem.id}
|
key={currentEnvelopeItem.id}
|
||||||
documentDataId={currentEnvelopeItem.documentDataId}
|
|
||||||
customPageRenderer={EnvelopeSignerPageRenderer}
|
customPageRenderer={EnvelopeSignerPageRenderer}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -165,6 +240,22 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mobile widget - Additional padding to allow users to scroll */}
|
||||||
|
<div className="block pb-28 lg:hidden">
|
||||||
|
<DocumentSigningMobileWidget />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hidePoweredBy && (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<span>Powered by</span>
|
||||||
|
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -39,12 +39,14 @@ export interface DocumentSigningRejectDialogProps {
|
|||||||
documentId: number;
|
documentId: number;
|
||||||
token: string;
|
token: string;
|
||||||
onRejected?: (reason: string) => void | Promise<void>;
|
onRejected?: (reason: string) => void | Promise<void>;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentSigningRejectDialog({
|
export function DocumentSigningRejectDialog({
|
||||||
documentId,
|
documentId,
|
||||||
token,
|
token,
|
||||||
onRejected,
|
onRejected,
|
||||||
|
trigger,
|
||||||
}: DocumentSigningRejectDialogProps) {
|
}: DocumentSigningRejectDialogProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -108,9 +110,11 @@ export function DocumentSigningRejectDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline">
|
{trigger ?? (
|
||||||
<Trans>Reject Document</Trans>
|
<Button variant="outline">
|
||||||
</Button>
|
<Trans>Reject Document</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@ -1,21 +1,30 @@
|
|||||||
import { createContext, useContext, useMemo, useState } from 'react';
|
import { createContext, useContext, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
EnvelopeType,
|
||||||
type Field,
|
type Field,
|
||||||
FieldType,
|
FieldType,
|
||||||
type Recipient,
|
type Recipient,
|
||||||
RecipientRole,
|
RecipientRole,
|
||||||
SigningStatus,
|
SigningStatus,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
import { prop, sortBy } from 'remeda';
|
||||||
|
|
||||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import {
|
||||||
|
isFieldUnsignedAndRequired,
|
||||||
|
isRequiredField,
|
||||||
|
} from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
|
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
||||||
|
|
||||||
export type EnvelopeSigningContextValue = {
|
export type EnvelopeSigningContextValue = {
|
||||||
|
isDirectTemplate: boolean;
|
||||||
|
|
||||||
fullName: string;
|
fullName: string;
|
||||||
setFullName: (_value: string) => void;
|
setFullName: (_value: string) => void;
|
||||||
email: string;
|
email: string;
|
||||||
@ -32,7 +41,8 @@ export type EnvelopeSigningContextValue = {
|
|||||||
recipient: EnvelopeForSigningResponse['recipient'];
|
recipient: EnvelopeForSigningResponse['recipient'];
|
||||||
recipientFieldsRemaining: Field[];
|
recipientFieldsRemaining: Field[];
|
||||||
recipientFields: Field[];
|
recipientFields: Field[];
|
||||||
selectedRecipientFields: Field[];
|
requiredRecipientFields: Field[];
|
||||||
|
selectedAssistantRecipientFields: Field[];
|
||||||
nextRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
|
nextRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
|
||||||
otherRecipientCompletedFields: (Field & {
|
otherRecipientCompletedFields: (Field & {
|
||||||
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus' | 'role'>;
|
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus' | 'role'>;
|
||||||
@ -42,7 +52,11 @@ export type EnvelopeSigningContextValue = {
|
|||||||
setSelectedAssistantRecipientId: (_value: number | null) => void;
|
setSelectedAssistantRecipientId: (_value: number | null) => void;
|
||||||
selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
|
selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
|
||||||
|
|
||||||
signField: (_fieldId: number, _value: TSignEnvelopeFieldValue) => Promise<void>;
|
signField: (
|
||||||
|
_fieldId: number,
|
||||||
|
_value: TSignEnvelopeFieldValue,
|
||||||
|
authOptions?: TRecipientActionAuth,
|
||||||
|
) => Promise<Pick<Field, 'id' | 'inserted'>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
|
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
|
||||||
@ -85,26 +99,31 @@ export const EnvelopeSigningProvider = ({
|
|||||||
|
|
||||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||||
|
|
||||||
const {
|
const isDirectTemplate = envelope.type === EnvelopeType.TEMPLATE;
|
||||||
mutateAsync: completeDocument,
|
|
||||||
isPending,
|
|
||||||
isSuccess,
|
|
||||||
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
|
||||||
|
|
||||||
const { mutateAsync: signEnvelopeField } = trpc.envelope.field.sign.useMutation({
|
const { mutateAsync: signEnvelopeField } = trpc.envelope.field.sign.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
console.log('signEnvelopeField', data);
|
|
||||||
|
|
||||||
const newRecipientFields = envelopeData.recipient.fields.map((field) =>
|
|
||||||
field.id === data.signedField.id ? data.signedField : field,
|
|
||||||
);
|
|
||||||
|
|
||||||
setEnvelopeData((prev) => ({
|
setEnvelopeData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
envelope: {
|
||||||
|
...prev.envelope,
|
||||||
|
recipients: prev.envelope.recipients.map((recipient) =>
|
||||||
|
recipient.id === data.signedField.recipientId
|
||||||
|
? {
|
||||||
|
...recipient,
|
||||||
|
fields: recipient.fields.map((field) =>
|
||||||
|
field.id === data.signedField.id ? data.signedField : field,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: recipient,
|
||||||
|
),
|
||||||
|
},
|
||||||
recipient: {
|
recipient: {
|
||||||
...prev.recipient,
|
...prev.recipient,
|
||||||
fields: newRecipientFields,
|
fields: prev.recipient.fields.map((field) =>
|
||||||
|
field.id === data.signedField.id ? data.signedField : field,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@ -148,6 +167,49 @@ export const EnvelopeSigningProvider = ({
|
|||||||
})(),
|
})(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fields that are still required to be signed by the actual recipient.
|
||||||
|
*/
|
||||||
|
const recipientFieldsRemaining = useMemo(() => {
|
||||||
|
const requiredFields = envelopeData.recipient.fields
|
||||||
|
.filter((field) => isFieldUnsignedAndRequired(field))
|
||||||
|
.map((field) => {
|
||||||
|
const envelopeItem = envelope.envelopeItems.find(
|
||||||
|
(item) => item.id === field.envelopeItemId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!envelopeItem) {
|
||||||
|
throw new Error('Missing envelope item');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
envelopeItemOrder: envelopeItem.order,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortBy(
|
||||||
|
requiredFields,
|
||||||
|
[prop('envelopeItemOrder'), 'asc'],
|
||||||
|
[prop('page'), 'asc'],
|
||||||
|
[prop('positionY'), 'asc'],
|
||||||
|
);
|
||||||
|
}, [envelopeData.recipient.fields]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All the required fields for the actual recipient.
|
||||||
|
*/
|
||||||
|
const requiredRecipientFields = useMemo(() => {
|
||||||
|
return envelopeData.recipient.fields.filter((field) => isRequiredField(field));
|
||||||
|
}, [envelopeData.recipient.fields]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All the fields for the actual recipient.
|
||||||
|
*/
|
||||||
|
const recipientFields = useMemo(() => {
|
||||||
|
return envelopeData.recipient.fields;
|
||||||
|
}, [envelopeData.recipient.fields]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assistant recipients are those that have a signing order after the assistant.
|
* Assistant recipients are those that have a signing order after the assistant.
|
||||||
*/
|
*/
|
||||||
@ -181,22 +243,8 @@ export const EnvelopeSigningProvider = ({
|
|||||||
return envelope.recipients.find((r) => r.id === selectedAssistantRecipientId) || null;
|
return envelope.recipients.find((r) => r.id === selectedAssistantRecipientId) || null;
|
||||||
}, [envelope.recipients, selectedAssistantRecipientId]);
|
}, [envelope.recipients, selectedAssistantRecipientId]);
|
||||||
|
|
||||||
/**
|
const selectedAssistantRecipientFields = useMemo(() => {
|
||||||
* The fields that are still required to be signed by the current recipient.
|
return assistantFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
|
||||||
*/
|
|
||||||
const recipientFieldsRemaining = useMemo(() => {
|
|
||||||
return envelopeData.recipient.fields.filter((field) => isFieldUnsignedAndRequired(field));
|
|
||||||
}, [envelopeData.recipient.fields]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All the fields for the current recipient.
|
|
||||||
*/
|
|
||||||
const recipientFields = useMemo(() => {
|
|
||||||
return envelopeData.recipient.fields;
|
|
||||||
}, [envelopeData.recipient.fields]);
|
|
||||||
|
|
||||||
const selectedRecipientFields = useMemo(() => {
|
|
||||||
return recipientFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
|
|
||||||
}, [recipientFields, selectedAssistantRecipient]);
|
}, [recipientFields, selectedAssistantRecipient]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -241,20 +289,91 @@ export const EnvelopeSigningProvider = ({
|
|||||||
: null;
|
: null;
|
||||||
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
|
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
|
||||||
|
|
||||||
const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
|
const signField = async (
|
||||||
console.log('insertField', fieldId, fieldValue);
|
fieldId: number,
|
||||||
|
fieldValue: TSignEnvelopeFieldValue,
|
||||||
|
authOptions?: TRecipientActionAuth,
|
||||||
|
) => {
|
||||||
|
// Set the field locally for direct templates.
|
||||||
|
if (isDirectTemplate) {
|
||||||
|
const signedField = handleDirectTemplateFieldInsertion(fieldId, fieldValue);
|
||||||
|
|
||||||
await signEnvelopeField({
|
return signedField;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { signedField } = await signEnvelopeField({
|
||||||
token: envelopeData.recipient.token,
|
token: envelopeData.recipient.token,
|
||||||
fieldId,
|
fieldId,
|
||||||
fieldValue,
|
fieldValue,
|
||||||
authOptions: undefined,
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return signedField;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDirectTemplateFieldInsertion = (
|
||||||
|
fieldId: number,
|
||||||
|
fieldValue: TSignEnvelopeFieldValue,
|
||||||
|
) => {
|
||||||
|
const foundField = recipient.fields.find((field) => field.id === fieldId);
|
||||||
|
|
||||||
|
if (!foundField) {
|
||||||
|
throw new Error('Not possible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertionValues = extractFieldInsertionValues({
|
||||||
|
fieldValue,
|
||||||
|
field: foundField,
|
||||||
|
documentMeta: envelope.documentMeta,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedField = {
|
||||||
|
...foundField,
|
||||||
|
...insertionValues,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fieldValue.type === FieldType.SIGNATURE) {
|
||||||
|
const isBase64 = isBase64Image(fieldValue.value || '');
|
||||||
|
|
||||||
|
updatedField.signature = fieldValue.value
|
||||||
|
? {
|
||||||
|
signatureImageAsBase64: isBase64 ? fieldValue.value : null,
|
||||||
|
typedSignature: isBase64 ? null : fieldValue.value,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
created: new Date(),
|
||||||
|
// Dummy IDs.
|
||||||
|
id: 0,
|
||||||
|
fieldId: 0,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnvelopeData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
envelope: {
|
||||||
|
...prev.envelope,
|
||||||
|
recipients: prev.envelope.recipients.map((r) =>
|
||||||
|
r.id === recipient.id
|
||||||
|
? {
|
||||||
|
...r,
|
||||||
|
fields: r.fields.map((field) => (field.id === fieldId ? updatedField : field)),
|
||||||
|
}
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
recipient: {
|
||||||
|
...prev.recipient,
|
||||||
|
fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return updatedField;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnvelopeSigningContext.Provider
|
<EnvelopeSigningContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
isDirectTemplate,
|
||||||
fullName,
|
fullName,
|
||||||
setFullName,
|
setFullName,
|
||||||
email,
|
email,
|
||||||
@ -270,6 +389,7 @@ export const EnvelopeSigningProvider = ({
|
|||||||
recipient,
|
recipient,
|
||||||
recipientFieldsRemaining,
|
recipientFieldsRemaining,
|
||||||
recipientFields,
|
recipientFields,
|
||||||
|
requiredRecipientFields,
|
||||||
nextRecipient,
|
nextRecipient,
|
||||||
|
|
||||||
otherRecipientCompletedFields,
|
otherRecipientCompletedFields,
|
||||||
@ -277,7 +397,7 @@ export const EnvelopeSigningProvider = ({
|
|||||||
assistantFields,
|
assistantFields,
|
||||||
setSelectedAssistantRecipientId,
|
setSelectedAssistantRecipientId,
|
||||||
selectedAssistantRecipient,
|
selectedAssistantRecipient,
|
||||||
selectedRecipientFields,
|
selectedAssistantRecipientFields,
|
||||||
|
|
||||||
signField,
|
signField,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -0,0 +1,248 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Paperclip, Plus, X } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type DocumentAttachmentsPopoverProps = {
|
||||||
|
envelopeId: string;
|
||||||
|
buttonClassName?: string;
|
||||||
|
buttonSize?: 'sm' | 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZAttachmentFormSchema = z.object({
|
||||||
|
label: z.string().min(1, 'Label is required'),
|
||||||
|
url: z.string().url('Must be a valid URL'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
||||||
|
|
||||||
|
export const DocumentAttachmentsPopover = ({
|
||||||
|
envelopeId,
|
||||||
|
buttonClassName,
|
||||||
|
buttonSize,
|
||||||
|
}: DocumentAttachmentsPopoverProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
|
||||||
|
envelopeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createAttachment, isPending: isCreating } =
|
||||||
|
trpc.envelope.attachment.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.envelope.attachment.find.invalidate({ envelopeId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: deleteAttachment } = trpc.envelope.attachment.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.envelope.attachment.find.invalidate({ envelopeId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<TAttachmentFormSchema>({
|
||||||
|
resolver: zodResolver(ZAttachmentFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
label: '',
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: TAttachmentFormSchema) => {
|
||||||
|
try {
|
||||||
|
await createAttachment({
|
||||||
|
envelopeId,
|
||||||
|
data: {
|
||||||
|
label: data.label,
|
||||||
|
data: data.url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
setIsAdding(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`Attachment added successfully.`),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteAttachment = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteAttachment({ id });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`Attachment removed successfully.`),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<Trans>Attachments</Trans>
|
||||||
|
{attachments && attachments.data.length > 0 && (
|
||||||
|
<span className="ml-1">({attachments.data.length})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent className="w-96" align="end">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">
|
||||||
|
<Trans>Attachments</Trans>
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
<Trans>Add links to relevant documents or resources.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{attachments && attachments.data.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attachments?.data.map((attachment) => (
|
||||||
|
<div
|
||||||
|
key={attachment.id}
|
||||||
|
className="border-border flex items-center justify-between rounded-md border p-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{attachment.label}</p>
|
||||||
|
<a
|
||||||
|
href={attachment.data}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
|
||||||
|
>
|
||||||
|
{attachment.data}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void onDeleteAttachment(attachment.id)}
|
||||||
|
className="ml-2 h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAdding && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Add Attachment</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdding && (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="label"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={_(msg`Label`)} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="url" placeholder={_(msg`URL`)} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setIsAdding(false);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
|
||||||
|
<Trans>Add</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,10 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentData, EnvelopeItem } from '@prisma/client';
|
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||||
|
import { DownloadIcon } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import {
|
||||||
|
EnvelopeRenderProvider,
|
||||||
|
useCurrentEnvelopeRender,
|
||||||
|
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||||
@ -19,9 +23,10 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
|
|
||||||
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
|
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
|
||||||
import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer';
|
import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer';
|
||||||
import { ShareDocumentDownloadButton } from '../share-document-download-button';
|
|
||||||
|
|
||||||
export type DocumentCertificateQRViewProps = {
|
export type DocumentCertificateQRViewProps = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
@ -31,6 +36,7 @@ export type DocumentCertificateQRViewProps = {
|
|||||||
documentTeamUrl: string;
|
documentTeamUrl: string;
|
||||||
recipientCount?: number;
|
recipientCount?: number;
|
||||||
completedDate?: Date;
|
completedDate?: Date;
|
||||||
|
token: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentCertificateQRView = ({
|
export const DocumentCertificateQRView = ({
|
||||||
@ -41,6 +47,7 @@ export const DocumentCertificateQRView = ({
|
|||||||
documentTeamUrl,
|
documentTeamUrl,
|
||||||
recipientCount = 0,
|
recipientCount = 0,
|
||||||
completedDate,
|
completedDate,
|
||||||
|
token,
|
||||||
}: DocumentCertificateQRViewProps) => {
|
}: DocumentCertificateQRViewProps) => {
|
||||||
const { data: documentViaUser } = trpc.document.get.useQuery({
|
const { data: documentViaUser } = trpc.document.get.useQuery({
|
||||||
documentId,
|
documentId,
|
||||||
@ -92,6 +99,76 @@ export const DocumentCertificateQRView = ({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{internalVersion === 2 ? (
|
||||||
|
<EnvelopeRenderProvider envelope={{ envelopeItems }} token={token}>
|
||||||
|
<DocumentCertificateQrV2
|
||||||
|
title={title}
|
||||||
|
recipientCount={recipientCount}
|
||||||
|
formattedDate={formattedDate}
|
||||||
|
token={token}
|
||||||
|
/>
|
||||||
|
</EnvelopeRenderProvider>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
<p>
|
||||||
|
<Trans>{recipientCount} recipients</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<Trans>Completed on {formattedDate}</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EnvelopeDownloadDialog
|
||||||
|
envelopeId={envelopeItems[0].envelopeId}
|
||||||
|
envelopeStatus={DocumentStatus.COMPLETED}
|
||||||
|
envelopeItems={envelopeItems}
|
||||||
|
token={token}
|
||||||
|
trigger={
|
||||||
|
<Button type="button" variant="outline" className="flex-1">
|
||||||
|
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 w-full">
|
||||||
|
<PDFViewer
|
||||||
|
key={envelopeItems[0].id}
|
||||||
|
envelopeItem={envelopeItems[0]}
|
||||||
|
token={token}
|
||||||
|
version="signed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocumentCertificateQrV2Props = {
|
||||||
|
title: string;
|
||||||
|
recipientCount: number;
|
||||||
|
formattedDate: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentCertificateQrV2 = ({
|
||||||
|
title,
|
||||||
|
recipientCount,
|
||||||
|
formattedDate,
|
||||||
|
token,
|
||||||
|
}: DocumentCertificateQrV2Props) => {
|
||||||
|
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-start">
|
||||||
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
|
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-xl font-medium">{title}</h1>
|
<h1 className="text-xl font-medium">{title}</h1>
|
||||||
@ -106,21 +183,24 @@ export const DocumentCertificateQRView = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ShareDocumentDownloadButton title={title} documentData={envelopeItems[0].documentData} />
|
<EnvelopeDownloadDialog
|
||||||
|
envelopeId={envelopeItems[0].envelopeId}
|
||||||
|
envelopeStatus={DocumentStatus.COMPLETED}
|
||||||
|
envelopeItems={envelopeItems}
|
||||||
|
token={token}
|
||||||
|
trigger={
|
||||||
|
<Button type="button" variant="outline" className="flex-1">
|
||||||
|
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 w-full">
|
<div className="mt-12 w-full">
|
||||||
{internalVersion === 2 ? (
|
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
||||||
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
|
|
||||||
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
|
||||||
|
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||||
</EnvelopeRenderProvider>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -16,9 +16,9 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/l
|
|||||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@ -62,14 +62,18 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const response = await putPdfFile(file);
|
const payload = {
|
||||||
|
|
||||||
const { legacyDocumentId: id } = await createDocument({
|
|
||||||
title: file.name,
|
title: file.name,
|
||||||
documentDataId: response.id,
|
timezone: userTimezone,
|
||||||
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
|
|
||||||
folderId: folderId ?? undefined,
|
folderId: folderId ?? undefined,
|
||||||
});
|
} satisfies TCreateDocumentPayloadSchema;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const { envelopeId: id } = await createDocument(formData);
|
||||||
|
|
||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
@ -95,6 +99,10 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
AppErrorCode.LIMIT_EXCEEDED,
|
AppErrorCode.LIMIT_EXCEEDED,
|
||||||
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
|
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||||
)
|
)
|
||||||
|
.with(
|
||||||
|
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||||
|
() => msg`You have reached the limit of the number of files per envelope`,
|
||||||
|
)
|
||||||
.otherwise(() => msg`An error occurred while uploading your document.`);
|
.otherwise(() => msg`An error occurred while uploading your document.`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@ -441,9 +441,10 @@ export const DocumentEditForm = ({
|
|||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
key={document.documentData.id}
|
key={document.envelopeItems[0].id}
|
||||||
documentData={document.documentData}
|
envelopeItem={document.envelopeItems[0]}
|
||||||
document={document}
|
token={undefined}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -1,18 +1,16 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
|
|
||||||
export type DocumentPageViewButtonProps = {
|
export type DocumentPageViewButtonProps = {
|
||||||
envelope: TEnvelope;
|
envelope: TEnvelope;
|
||||||
@ -21,9 +19,6 @@ export type DocumentPageViewButtonProps = {
|
|||||||
export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => {
|
export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
|
||||||
const isRecipient = !!recipient;
|
const isRecipient = !!recipient;
|
||||||
@ -35,30 +30,12 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
|||||||
const documentsPath = formatDocumentsPath(envelope.team.url);
|
const documentsPath = formatDocumentsPath(envelope.team.url);
|
||||||
const formatPath = `${documentsPath}/${envelope.id}/edit`;
|
const formatPath = `${documentsPath}/${envelope.id}/edit`;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
// Todo; Envelopes - Support multiple items
|
|
||||||
const envelopeItem = envelope.envelopeItems[0];
|
|
||||||
|
|
||||||
if (!envelopeItem.documentData) {
|
|
||||||
throw new Error('No document available');
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData: envelopeItem.documentData, fileName: envelopeItem.title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isRecipient,
|
isRecipient,
|
||||||
isPending,
|
isPending,
|
||||||
isComplete,
|
isComplete,
|
||||||
isSigned,
|
isSigned,
|
||||||
|
internalVersion: envelope.internalVersion,
|
||||||
})
|
})
|
||||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||||
<Button className="w-full" asChild>
|
<Button className="w-full" asChild>
|
||||||
@ -93,10 +70,18 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
|||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<Button className="w-full" onClick={onDownloadClick}>
|
<EnvelopeDownloadDialog
|
||||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
envelopeId={envelope.id}
|
||||||
<Trans>Download</Trans>
|
envelopeStatus={envelope.status}
|
||||||
</Button>
|
envelopeItems={envelope.envelopeItems}
|
||||||
|
token={recipient?.token}
|
||||||
|
trigger={
|
||||||
|
<Button className="w-full">
|
||||||
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
.otherwise(() => null);
|
.otherwise(() => null);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
@ -16,13 +15,11 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Link, useNavigate } from 'react-router';
|
import { Link, useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -36,6 +33,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
||||||
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
||||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||||
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
@ -66,64 +64,6 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
|||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
const documentWithData = await trpcClient.document.get.query(
|
|
||||||
{
|
|
||||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
context: {
|
|
||||||
teamId: team?.id?.toString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const documentData = documentWithData?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: envelope.title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDownloadOriginalClick = async () => {
|
|
||||||
try {
|
|
||||||
const documentWithData = await trpcClient.document.get.query(
|
|
||||||
{
|
|
||||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
context: {
|
|
||||||
teamId: team?.id?.toString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const documentData = documentWithData?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: envelope.title, version: 'original' });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -146,17 +86,20 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isComplete && (
|
<EnvelopeDownloadDialog
|
||||||
<DropdownMenuItem onClick={onDownloadClick}>
|
envelopeId={envelope.id}
|
||||||
<Download className="mr-2 h-4 w-4" />
|
envelopeStatus={envelope.status}
|
||||||
<Trans>Download</Trans>
|
token={recipient?.token}
|
||||||
</DropdownMenuItem>
|
envelopeItems={envelope.envelopeItems}
|
||||||
)}
|
trigger={
|
||||||
|
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
<div>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
<Trans>Download Original</Trans>
|
<Trans>Download</Trans>
|
||||||
</DropdownMenuItem>
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={`${documentsPath}/${envelope.id}/logs`}>
|
<Link to={`${documentsPath}/${envelope.id}/logs`}>
|
||||||
@ -230,7 +173,8 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
|||||||
|
|
||||||
{isDuplicateDialogOpen && (
|
{isDuplicateDialogOpen && (
|
||||||
<DocumentDuplicateDialog
|
<DocumentDuplicateDialog
|
||||||
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
id={envelope.id}
|
||||||
|
token={recipient?.token}
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
|
import { TooltipArrow } from '@radix-ui/react-tooltip';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
@ -12,7 +15,7 @@ import {
|
|||||||
PlusIcon,
|
PlusIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
@ -24,6 +27,12 @@ import { SignatureIcon } from '@documenso/ui/icons/signature';
|
|||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@documenso/ui/primitives/tooltip';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DocumentPageViewRecipientsProps = {
|
export type DocumentPageViewRecipientsProps = {
|
||||||
@ -37,8 +46,24 @@ export const DocumentPageViewRecipients = ({
|
|||||||
}: DocumentPageViewRecipientsProps) => {
|
}: DocumentPageViewRecipientsProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const recipients = envelope.recipients;
|
const recipients = envelope.recipients;
|
||||||
|
const [shouldHighlightCopyButtons, setShouldHighlightCopyButtons] = useState(false);
|
||||||
|
|
||||||
|
// Check for action=view-tokens query parameter and set highlighting state
|
||||||
|
useEffect(() => {
|
||||||
|
const hasViewTokensAction = searchParams.get('action') === 'copy-links';
|
||||||
|
|
||||||
|
if (hasViewTokensAction) {
|
||||||
|
setShouldHighlightCopyButtons(true);
|
||||||
|
|
||||||
|
// Remove the query parameter immediately
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.delete('action');
|
||||||
|
setSearchParams(params);
|
||||||
|
}
|
||||||
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||||
@ -69,7 +94,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipients.map((recipient) => (
|
{recipients.map((recipient, i) => (
|
||||||
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||||
@ -159,15 +184,33 @@ export const DocumentPageViewRecipients = ({
|
|||||||
{envelope.status === DocumentStatus.PENDING &&
|
{envelope.status === DocumentStatus.PENDING &&
|
||||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||||
recipient.role !== RecipientRole.CC && (
|
recipient.role !== RecipientRole.CC && (
|
||||||
<CopyTextButton
|
<TooltipProvider>
|
||||||
value={formatSigningLink(recipient.token)}
|
<Tooltip open={shouldHighlightCopyButtons && i === 0}>
|
||||||
onCopySuccess={() => {
|
<TooltipTrigger asChild>
|
||||||
toast({
|
<div
|
||||||
title: _(msg`Copied to clipboard`),
|
className={shouldHighlightCopyButtons ? 'animate-pulse' : ''}
|
||||||
description: _(msg`The signing link has been copied to your clipboard.`),
|
onClick={() => setShouldHighlightCopyButtons(false)}
|
||||||
});
|
>
|
||||||
}}
|
<CopyTextButton
|
||||||
/>
|
value={formatSigningLink(recipient.token)}
|
||||||
|
onCopySuccess={() => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Copied to clipboard`),
|
||||||
|
description: _(
|
||||||
|
msg`The signing link has been copied to your clipboard.`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
setShouldHighlightCopyButtons(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={2}>
|
||||||
|
<Trans>Copy Signing Links</Trans>
|
||||||
|
<TooltipArrow className="fill-background" />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -13,9 +13,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
|||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
||||||
import {
|
import {
|
||||||
@ -73,14 +73,18 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const response = await putPdfFile(file);
|
const payload = {
|
||||||
|
|
||||||
const { legacyDocumentId: id } = await createDocument({
|
|
||||||
title: file.name,
|
title: file.name,
|
||||||
documentDataId: response.id,
|
|
||||||
timezone: userTimezone,
|
timezone: userTimezone,
|
||||||
folderId: folderId ?? undefined,
|
folderId: folderId ?? undefined,
|
||||||
});
|
} satisfies TCreateDocumentPayloadSchema;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const { envelopeId: id } = await createDocument(formData);
|
||||||
|
|
||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
@ -108,6 +112,10 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
|
|||||||
AppErrorCode.LIMIT_EXCEEDED,
|
AppErrorCode.LIMIT_EXCEEDED,
|
||||||
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
|
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||||
)
|
)
|
||||||
|
.with(
|
||||||
|
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||||
|
() => msg`You have reached the limit of the number of files per envelope`,
|
||||||
|
)
|
||||||
.otherwise(() => msg`An error occurred while uploading your document.`);
|
.otherwise(() => msg`An error occurred while uploading your document.`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
import { EnvelopeType } from '@prisma/client';
|
||||||
|
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -13,9 +14,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
|||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
||||||
import {
|
import {
|
||||||
@ -51,7 +52,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
(timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone,
|
(timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { quota, remaining, refreshLimits } = useLimits();
|
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
@ -69,6 +70,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
if (!user.emailVerified) {
|
if (!user.emailVerified) {
|
||||||
return msg`Verify your email to upload documents.`;
|
return msg`Verify your email to upload documents.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [remaining.documents, user.emailVerified, team]);
|
}, [remaining.documents, user.emailVerified, team]);
|
||||||
|
|
||||||
@ -76,35 +78,24 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const result = await Promise.all(
|
const payload = {
|
||||||
files.map(async (file) => {
|
|
||||||
try {
|
|
||||||
const response = await putPdfFile(file);
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: file.name,
|
|
||||||
documentDataId: response.id,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
throw new Error('Failed to upload document');
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const envelopeItemsToCreate = result.filter(
|
|
||||||
(item): item is { title: string; documentDataId: string } => item !== undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { id } = await createEnvelope({
|
|
||||||
folderId,
|
folderId,
|
||||||
type,
|
type,
|
||||||
title: files[0].name,
|
title: files[0].name,
|
||||||
items: envelopeItemsToCreate,
|
|
||||||
meta: {
|
meta: {
|
||||||
timezone: userTimezone,
|
timezone: userTimezone,
|
||||||
},
|
},
|
||||||
}).catch((error) => {
|
} satisfies TCreateEnvelopePayload;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append('files', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await createEnvelope(formData).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
@ -138,6 +129,10 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
AppErrorCode.LIMIT_EXCEEDED,
|
AppErrorCode.LIMIT_EXCEEDED,
|
||||||
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||||
)
|
)
|
||||||
|
.with(
|
||||||
|
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||||
|
() => t`You have reached the limit of the number of files per envelope`,
|
||||||
|
)
|
||||||
.otherwise(() => t`An error occurred while uploading your document.`);
|
.otherwise(() => t`An error occurred while uploading your document.`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -151,12 +146,23 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFileDropRejected = () => {
|
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
||||||
|
const maxItemsReached = fileRejections.some((fileRejection) =>
|
||||||
|
fileRejection.errors.some((error) => error.code === DropzoneErrorCode.TooManyFiles),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (maxItemsReached) {
|
||||||
|
toast({
|
||||||
|
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
|
||||||
|
duration: 5000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title:
|
title: t`Upload failed`,
|
||||||
type === EnvelopeType.DOCUMENT
|
|
||||||
? t`Your document failed to upload.`
|
|
||||||
: t`Your template failed to upload.`,
|
|
||||||
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
@ -176,6 +182,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
onDropRejected={onFileDropRejected}
|
onDropRejected={onFileDropRejected}
|
||||||
type="envelope"
|
type="envelope"
|
||||||
|
maxFiles={maximumEnvelopeItemCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@ -96,7 +96,7 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
selectedRecipientId,
|
selectedRecipientId,
|
||||||
selectedEnvelopeItemId,
|
selectedEnvelopeItemId,
|
||||||
}: EnvelopeEditorFieldDragDropProps) => {
|
}: EnvelopeEditorFieldDragDropProps) => {
|
||||||
const { envelope, editorFields, isTemplate } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
@ -262,6 +262,10 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
};
|
};
|
||||||
}, [onMouseClick, onMouseMove, selectedField]);
|
}, [onMouseClick, onMouseMove, selectedField]);
|
||||||
|
|
||||||
|
const selectedRecipientColor = useMemo(() => {
|
||||||
|
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
|
||||||
|
}, [selectedRecipientId, getRecipientColorKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
|
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
|
||||||
@ -273,12 +277,23 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
onClick={() => setSelectedField(field.type)}
|
onClick={() => setSelectedField(field.type)}
|
||||||
onMouseDown={() => setSelectedField(field.type)}
|
onMouseDown={() => setSelectedField(field.type)}
|
||||||
data-selected={selectedField === field.type ? true : undefined}
|
data-selected={selectedField === field.type ? true : undefined}
|
||||||
className="group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-gray-200 px-4 transition-colors hover:border-blue-300 hover:bg-blue-50"
|
className={cn(
|
||||||
|
'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
|
||||||
|
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||||
field.className,
|
field.className,
|
||||||
|
{
|
||||||
|
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
|
||||||
|
'group-hover:text-recipient-blue': selectedRecipientColor === 'blue',
|
||||||
|
'group-hover:text-recipient-purple': selectedRecipientColor === 'purple',
|
||||||
|
'group-hover:text-recipient-orange': selectedRecipientColor === 'orange',
|
||||||
|
'group-hover:text-recipient-yellow': selectedRecipientColor === 'yellow',
|
||||||
|
'group-hover:text-recipient-pink': selectedRecipientColor === 'pink',
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
|
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
|
||||||
@ -291,9 +306,9 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
{selectedField && (
|
{selectedField && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
|
'text-muted-foreground dark:text-muted-background font-noto pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
|
||||||
// selectedSignerStyles?.base,
|
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
|
||||||
RECIPIENT_COLOR_STYLES.yellow.base, // Todo: Envelopes
|
selectedField === FieldType.SIGNATURE && 'font-signature',
|
||||||
{
|
{
|
||||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||||
'dark:text-black/60': isFieldWithinBounds,
|
'dark:text-black/60': isFieldWithinBounds,
|
||||||
|
|||||||
@ -3,15 +3,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import type { FieldType } from '@prisma/client';
|
import type { FieldType } from '@prisma/client';
|
||||||
import Konva from 'konva';
|
import Konva from 'konva';
|
||||||
import type { Layer } from 'konva/lib/Layer';
|
|
||||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||||
import type { Transformer } from 'konva/lib/shapes/Transformer';
|
import type { Transformer } from 'konva/lib/shapes/Transformer';
|
||||||
import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react';
|
import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react';
|
||||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
|
||||||
import { usePageContext } from 'react-pdf';
|
|
||||||
|
|
||||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
|
||||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||||
|
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||||
@ -21,32 +18,16 @@ import {
|
|||||||
convertPixelToPercentage,
|
convertPixelToPercentage,
|
||||||
} from '@documenso/lib/universal/field-renderer/field-renderer';
|
} from '@documenso/lib/universal/field-renderer/field-renderer';
|
||||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||||
|
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||||
|
|
||||||
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||||
|
|
||||||
export default function EnvelopeEditorFieldsPageRenderer() {
|
export default function EnvelopeEditorFieldsPageRenderer() {
|
||||||
const pageContext = usePageContext();
|
const { t, i18n } = useLingui();
|
||||||
|
|
||||||
if (!pageContext) {
|
|
||||||
throw new Error('Unable to find Page context.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { _className, page, rotate, scale } = pageContext;
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
|
||||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const stage = useRef<Konva.Stage | null>(null);
|
|
||||||
const pageLayer = useRef<Layer | null>(null);
|
|
||||||
const interactiveTransformer = useRef<Transformer | null>(null);
|
const interactiveTransformer = useRef<Transformer | null>(null);
|
||||||
|
|
||||||
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
|
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
|
||||||
@ -54,10 +35,17 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
||||||
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
||||||
|
|
||||||
const viewport = useMemo(
|
const {
|
||||||
() => page.getViewport({ scale, rotation: rotate }),
|
stage,
|
||||||
[page, rotate, scale],
|
pageLayer,
|
||||||
);
|
canvasElement,
|
||||||
|
konvaContainer,
|
||||||
|
pageContext,
|
||||||
|
scaledViewport,
|
||||||
|
unscaledViewport,
|
||||||
|
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
||||||
|
|
||||||
|
const { _className, scale } = pageContext;
|
||||||
|
|
||||||
const localPageFields = useMemo(
|
const localPageFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -68,47 +56,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
[editorFields.localFields, pageContext.pageNumber],
|
[editorFields.localFields, pageContext.pageNumber],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Custom renderer from Konva examples.
|
|
||||||
useEffect(
|
|
||||||
function drawPageOnCanvas() {
|
|
||||||
if (!page) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { current: canvas } = canvasElement;
|
|
||||||
const { current: container } = konvaContainer;
|
|
||||||
|
|
||||||
if (!canvas || !container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderContext: RenderParameters = {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
|
||||||
viewport,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancellable = page.render(renderContext);
|
|
||||||
const runningTask = cancellable;
|
|
||||||
|
|
||||||
cancellable.promise.catch(() => {
|
|
||||||
// Intentionally empty
|
|
||||||
});
|
|
||||||
|
|
||||||
void cancellable.promise.then(() => {
|
|
||||||
createPageCanvas(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
runningTask.cancel();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[page, viewport],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||||
console.log('Field resized or moved');
|
|
||||||
|
|
||||||
const { current: container } = canvasElement;
|
const { current: container } = canvasElement;
|
||||||
|
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@ -120,6 +68,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
const fieldGroup = event.target as Konva.Group;
|
const fieldGroup = event.target as Konva.Group;
|
||||||
const fieldFormId = fieldGroup.id();
|
const fieldFormId = fieldGroup.id();
|
||||||
|
|
||||||
|
// Note: This values are scaled.
|
||||||
const {
|
const {
|
||||||
width: fieldPixelWidth,
|
width: fieldPixelWidth,
|
||||||
height: fieldPixelHeight,
|
height: fieldPixelHeight,
|
||||||
@ -130,7 +79,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
skipShadow: true,
|
skipShadow: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(container);
|
const pageHeight = scaledViewport.height;
|
||||||
|
const pageWidth = scaledViewport.width;
|
||||||
|
|
||||||
// Calculate x and y as a percentage of the page width and height
|
// Calculate x and y as a percentage of the page width and height
|
||||||
const positionPercentX = (fieldX / pageWidth) * 100;
|
const positionPercentX = (fieldX / pageWidth) * 100;
|
||||||
@ -153,7 +103,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
fieldUpdates.height = fieldPageHeight;
|
fieldUpdates.height = fieldPageHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Todo: envelopes Use id
|
|
||||||
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
|
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
|
||||||
|
|
||||||
// Select the field if it is not already selected.
|
// Select the field if it is not already selected.
|
||||||
@ -164,9 +113,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
pageLayer.current?.batchDraw();
|
pageLayer.current?.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFieldOnLayer = (field: TLocalField) => {
|
const unsafeRenderFieldOnLayer = (field: TLocalField) => {
|
||||||
if (!pageLayer.current || !interactiveTransformer.current) {
|
if (!pageLayer.current) {
|
||||||
console.error('Layer not loaded yet');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,7 +122,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
const isFieldEditable =
|
const isFieldEditable =
|
||||||
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
|
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
|
||||||
|
|
||||||
const { fieldGroup, isFirstRender } = renderField({
|
const { fieldGroup } = renderField({
|
||||||
|
scale,
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
field: {
|
field: {
|
||||||
renderId: field.formId,
|
renderId: field.formId,
|
||||||
@ -183,8 +132,9 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
inserted: false,
|
inserted: false,
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
},
|
},
|
||||||
pageWidth: viewport.width,
|
translations: getClientSideFieldTranslations(i18n),
|
||||||
pageHeight: viewport.height,
|
pageWidth: unscaledViewport.width,
|
||||||
|
pageHeight: unscaledViewport.height,
|
||||||
color: getRecipientColorKey(field.recipientId),
|
color: getRecipientColorKey(field.recipientId),
|
||||||
editable: isFieldEditable,
|
editable: isFieldEditable,
|
||||||
mode: 'edit',
|
mode: 'edit',
|
||||||
@ -209,25 +159,24 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
fieldGroup.on('dragend', handleResizeOrMove);
|
fieldGroup.on('dragend', handleResizeOrMove);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderFieldOnLayer = (field: TLocalField) => {
|
||||||
|
try {
|
||||||
|
unsafeRenderFieldOnLayer(field);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setRenderError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
* Initialize the Konva page canvas and all fields and interactions.
|
||||||
*/
|
*/
|
||||||
const createPageCanvas = (container: HTMLDivElement) => {
|
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||||
stage.current = new Konva.Stage({
|
|
||||||
container,
|
|
||||||
width: viewport.width,
|
|
||||||
height: viewport.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the main layer for interactive elements.
|
|
||||||
pageLayer.current = new Konva.Layer();
|
|
||||||
stage.current?.add(pageLayer.current);
|
|
||||||
|
|
||||||
// Initialize snap guides layer
|
// Initialize snap guides layer
|
||||||
// snapGuideLayer.current = initializeSnapGuides(stage.current);
|
// snapGuideLayer.current = initializeSnapGuides(stage.current);
|
||||||
|
|
||||||
// Add transformer for resizing and rotating.
|
// Add transformer for resizing and rotating.
|
||||||
interactiveTransformer.current = createInteractiveTransformer(stage.current, pageLayer.current);
|
interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer);
|
||||||
|
|
||||||
// Render the fields.
|
// Render the fields.
|
||||||
for (const field of localPageFields) {
|
for (const field of localPageFields) {
|
||||||
@ -235,12 +184,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle stage click to deselect.
|
// Handle stage click to deselect.
|
||||||
stage.current?.on('click', (e) => {
|
currentStage.on('mousedown', (e) => {
|
||||||
removePendingField();
|
removePendingField();
|
||||||
|
|
||||||
if (e.target === stage.current) {
|
if (e.target === stage.current) {
|
||||||
setSelectedFields([]);
|
setSelectedFields([]);
|
||||||
pageLayer.current?.batchDraw();
|
currentPageLayer.batchDraw();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -267,12 +216,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
setSelectedFields([e.target]);
|
setSelectedFields([e.target]);
|
||||||
};
|
};
|
||||||
|
|
||||||
stage.current?.on('dragstart', onDragStartOrEnd);
|
currentStage.on('dragstart', onDragStartOrEnd);
|
||||||
stage.current?.on('dragend', onDragStartOrEnd);
|
currentStage.on('dragend', onDragStartOrEnd);
|
||||||
stage.current?.on('transformstart', () => setIsFieldChanging(true));
|
currentStage.on('transformstart', () => setIsFieldChanging(true));
|
||||||
stage.current?.on('transformend', () => setIsFieldChanging(false));
|
currentStage.on('transformend', () => setIsFieldChanging(false));
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
currentPageLayer.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -284,7 +233,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
* - Selecting multiple fields
|
* - Selecting multiple fields
|
||||||
* - Selecting empty area to create fields
|
* - Selecting empty area to create fields
|
||||||
*/
|
*/
|
||||||
const createInteractiveTransformer = (stage: Konva.Stage, layer: Konva.Layer) => {
|
const createInteractiveTransformer = (
|
||||||
|
currentStage: Konva.Stage,
|
||||||
|
currentPageLayer: Konva.Layer,
|
||||||
|
) => {
|
||||||
const transformer = new Konva.Transformer({
|
const transformer = new Konva.Transformer({
|
||||||
rotateEnabled: false,
|
rotateEnabled: false,
|
||||||
keepRatio: false,
|
keepRatio: false,
|
||||||
@ -301,36 +253,36 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
layer.add(transformer);
|
currentPageLayer.add(transformer);
|
||||||
|
|
||||||
// Add selection rectangle.
|
// Add selection rectangle.
|
||||||
const selectionRectangle = new Konva.Rect({
|
const selectionRectangle = new Konva.Rect({
|
||||||
fill: 'rgba(24, 160, 251, 0.3)',
|
fill: 'rgba(24, 160, 251, 0.3)',
|
||||||
visible: false,
|
visible: false,
|
||||||
});
|
});
|
||||||
layer.add(selectionRectangle);
|
currentPageLayer.add(selectionRectangle);
|
||||||
|
|
||||||
let x1: number;
|
let x1: number;
|
||||||
let y1: number;
|
let y1: number;
|
||||||
let x2: number;
|
let x2: number;
|
||||||
let y2: number;
|
let y2: number;
|
||||||
|
|
||||||
stage.on('mousedown touchstart', (e) => {
|
currentStage.on('mousedown touchstart', (e) => {
|
||||||
// do nothing if we mousedown on any shape
|
// do nothing if we mousedown on any shape
|
||||||
if (e.target !== stage) {
|
if (e.target !== currentStage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointerPosition = stage.getPointerPosition();
|
const pointerPosition = currentStage.getPointerPosition();
|
||||||
|
|
||||||
if (!pointerPosition) {
|
if (!pointerPosition) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
x1 = pointerPosition.x;
|
x1 = pointerPosition.x / scale;
|
||||||
y1 = pointerPosition.y;
|
y1 = pointerPosition.y / scale;
|
||||||
x2 = pointerPosition.x;
|
x2 = pointerPosition.x / scale;
|
||||||
y2 = pointerPosition.y;
|
y2 = pointerPosition.y / scale;
|
||||||
|
|
||||||
selectionRectangle.setAttrs({
|
selectionRectangle.setAttrs({
|
||||||
x: x1,
|
x: x1,
|
||||||
@ -341,7 +293,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
stage.on('mousemove touchmove', () => {
|
currentStage.on('mousemove touchmove', () => {
|
||||||
// do nothing if we didn't start selection
|
// do nothing if we didn't start selection
|
||||||
if (!selectionRectangle.visible()) {
|
if (!selectionRectangle.visible()) {
|
||||||
return;
|
return;
|
||||||
@ -349,14 +301,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
|
|
||||||
selectionRectangle.moveToTop();
|
selectionRectangle.moveToTop();
|
||||||
|
|
||||||
const pointerPosition = stage.getPointerPosition();
|
const pointerPosition = currentStage.getPointerPosition();
|
||||||
|
|
||||||
if (!pointerPosition) {
|
if (!pointerPosition) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
x2 = pointerPosition.x;
|
x2 = pointerPosition.x / scale;
|
||||||
y2 = pointerPosition.y;
|
y2 = pointerPosition.y / scale;
|
||||||
|
|
||||||
selectionRectangle.setAttrs({
|
selectionRectangle.setAttrs({
|
||||||
x: Math.min(x1, x2),
|
x: Math.min(x1, x2),
|
||||||
@ -366,7 +318,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
stage.on('mouseup touchend', () => {
|
currentStage.on('mouseup touchend', () => {
|
||||||
// do nothing if we didn't start selection
|
// do nothing if we didn't start selection
|
||||||
if (!selectionRectangle.visible()) {
|
if (!selectionRectangle.visible()) {
|
||||||
return;
|
return;
|
||||||
@ -377,38 +329,41 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
selectionRectangle.visible(false);
|
selectionRectangle.visible(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
const stageFieldGroups = stage.find('.field-group') || [];
|
const stageFieldGroups = currentStage.find('.field-group') || [];
|
||||||
const box = selectionRectangle.getClientRect();
|
const box = selectionRectangle.getClientRect();
|
||||||
const selectedFieldGroups = stageFieldGroups.filter(
|
const selectedFieldGroups = stageFieldGroups.filter(
|
||||||
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
|
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
|
||||||
);
|
);
|
||||||
setSelectedFields(selectedFieldGroups);
|
setSelectedFields(selectedFieldGroups);
|
||||||
|
|
||||||
|
const unscaledBoxWidth = box.width / scale;
|
||||||
|
const unscaledBoxHeight = box.height / scale;
|
||||||
|
|
||||||
// Create a field if no items are selected or the size is too small.
|
// Create a field if no items are selected or the size is too small.
|
||||||
if (
|
if (
|
||||||
selectedFieldGroups.length === 0 &&
|
selectedFieldGroups.length === 0 &&
|
||||||
canvasElement.current &&
|
canvasElement.current &&
|
||||||
box.width > MIN_FIELD_WIDTH_PX &&
|
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
|
||||||
box.height > MIN_FIELD_HEIGHT_PX &&
|
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
|
||||||
editorFields.selectedRecipient &&
|
editorFields.selectedRecipient &&
|
||||||
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
|
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
|
||||||
) {
|
) {
|
||||||
const pendingFieldCreation = new Konva.Rect({
|
const pendingFieldCreation = new Konva.Rect({
|
||||||
name: 'pending-field-creation',
|
name: 'pending-field-creation',
|
||||||
x: box.x,
|
x: box.x / scale,
|
||||||
y: box.y,
|
y: box.y / scale,
|
||||||
width: box.width,
|
width: unscaledBoxWidth,
|
||||||
height: box.height,
|
height: unscaledBoxHeight,
|
||||||
fill: 'rgba(24, 160, 251, 0.3)',
|
fill: 'rgba(24, 160, 251, 0.3)',
|
||||||
});
|
});
|
||||||
|
|
||||||
layer.add(pendingFieldCreation);
|
currentPageLayer.add(pendingFieldCreation);
|
||||||
setPendingFieldCreation(pendingFieldCreation);
|
setPendingFieldCreation(pendingFieldCreation);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clicks should select/deselect shapes
|
// Clicks should select/deselect shapes
|
||||||
stage.on('click tap', function (e) {
|
currentStage.on('click tap', function (e) {
|
||||||
// if we are selecting with rect, do nothing
|
// if we are selecting with rect, do nothing
|
||||||
if (
|
if (
|
||||||
selectionRectangle.visible() &&
|
selectionRectangle.visible() &&
|
||||||
@ -419,7 +374,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If empty area clicked, remove all selections
|
// If empty area clicked, remove all selections
|
||||||
if (e.target === stage) {
|
if (e.target === stage.current) {
|
||||||
setSelectedFields([]);
|
setSelectedFields([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -468,20 +423,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
group.name() === 'field-group' &&
|
group.name() === 'field-group' &&
|
||||||
!localPageFields.some((field) => field.formId === group.id())
|
!localPageFields.some((field) => field.formId === group.id())
|
||||||
) {
|
) {
|
||||||
console.log('Field removed, removing from canvas');
|
|
||||||
group.destroy();
|
group.destroy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// If it exists, rerender.
|
// If it exists, rerender.
|
||||||
localPageFields.forEach((field) => {
|
localPageFields.forEach((field) => {
|
||||||
console.log('Field created/updated, rendering on canvas');
|
|
||||||
renderFieldOnLayer(field);
|
renderFieldOnLayer(field);
|
||||||
});
|
});
|
||||||
|
|
||||||
// If it doesn't exist, render it.
|
|
||||||
//
|
|
||||||
|
|
||||||
// Rerender the transformer
|
// Rerender the transformer
|
||||||
interactiveTransformer.current?.forceUpdate();
|
interactiveTransformer.current?.forceUpdate();
|
||||||
|
|
||||||
@ -555,15 +505,13 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(canvasElement.current);
|
|
||||||
|
|
||||||
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
|
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
|
||||||
width: pixelWidth,
|
width: pixelWidth,
|
||||||
height: pixelHeight,
|
height: pixelHeight,
|
||||||
positionX: pixelX,
|
positionX: pixelX,
|
||||||
positionY: pixelY,
|
positionY: pixelY,
|
||||||
pageWidth,
|
pageWidth: unscaledViewport.width,
|
||||||
pageHeight,
|
pageHeight: unscaledViewport.height,
|
||||||
});
|
});
|
||||||
|
|
||||||
editorFields.addField({
|
editorFields.addField({
|
||||||
@ -597,7 +545,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
<div
|
||||||
|
className="relative w-full"
|
||||||
|
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||||
|
>
|
||||||
{selectedKonvaFieldGroups.length > 0 &&
|
{selectedKonvaFieldGroups.length > 0 &&
|
||||||
interactiveTransformer.current &&
|
interactiveTransformer.current &&
|
||||||
!isFieldChanging && (
|
!isFieldChanging && (
|
||||||
@ -649,17 +600,23 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Todo: Envelopes - This will not overflow the page when close to edges */}
|
|
||||||
{pendingFieldCreation && (
|
{pendingFieldCreation && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: pendingFieldCreation.y() + pendingFieldCreation.getClientRect().height + 5 + 'px',
|
top:
|
||||||
left: pendingFieldCreation.x() + pendingFieldCreation.getClientRect().width / 2 + 'px',
|
pendingFieldCreation.y() * scale +
|
||||||
|
pendingFieldCreation.getClientRect().height +
|
||||||
|
5 +
|
||||||
|
'px',
|
||||||
|
left:
|
||||||
|
pendingFieldCreation.x() * scale +
|
||||||
|
pendingFieldCreation.getClientRect().width / 2 +
|
||||||
|
'px',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
}}
|
}}
|
||||||
className="text-muted-foreground grid w-fit grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border bg-white p-1 shadow-sm"
|
className="text-muted-foreground grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border bg-white p-1 shadow-sm"
|
||||||
>
|
>
|
||||||
{fieldButtonList.map((field) => (
|
{fieldButtonList.map((field) => (
|
||||||
<button
|
<button
|
||||||
@ -673,13 +630,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
{/* The element Konva will inject it's canvas into. */}
|
||||||
|
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||||
|
|
||||||
|
{/* Canvas the PDF will be rendered on. */}
|
||||||
<canvas
|
<canvas
|
||||||
className={`${_className}__canvas z-0`}
|
className={`${_className}__canvas z-0`}
|
||||||
height={viewport.height}
|
|
||||||
ref={canvasElement}
|
ref={canvasElement}
|
||||||
width={viewport.width}
|
height={scaledViewport.height}
|
||||||
|
width={scaledViewport.width}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { FieldType, RecipientRole } from '@prisma/client';
|
import { FieldType, RecipientRole } from '@prisma/client';
|
||||||
import { FileTextIcon } from 'lucide-react';
|
import { FileTextIcon } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
import { isDeepEqual } from 'remeda';
|
import { isDeepEqual } from 'remeda';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -20,12 +21,14 @@ import type {
|
|||||||
TNameFieldMeta,
|
TNameFieldMeta,
|
||||||
TNumberFieldMeta,
|
TNumberFieldMeta,
|
||||||
TRadioFieldMeta,
|
TRadioFieldMeta,
|
||||||
|
TSignatureFieldMeta,
|
||||||
TTextFieldMeta,
|
TTextFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
|
|
||||||
@ -37,6 +40,7 @@ import { EditorFieldInitialsForm } from '~/components/forms/editor/editor-field-
|
|||||||
import { EditorFieldNameForm } from '~/components/forms/editor/editor-field-name-form';
|
import { EditorFieldNameForm } from '~/components/forms/editor/editor-field-name-form';
|
||||||
import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-number-form';
|
import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-number-form';
|
||||||
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
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 { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
|
||||||
|
|
||||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||||
@ -60,8 +64,8 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
|||||||
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
|
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EnvelopeEditorPageFields = () => {
|
export const EnvelopeEditorFieldsPage = () => {
|
||||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
@ -104,14 +108,39 @@ export const EnvelopeEditorPageFields = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full">
|
<div className="relative flex h-full">
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col overflow-y-auto">
|
||||||
{/* Horizontal envelope item selector */}
|
{/* Horizontal envelope item selector */}
|
||||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||||
|
|
||||||
{/* Document View */}
|
{/* Document View */}
|
||||||
<div className="mt-4 flex justify-center">
|
<div className="mt-4 flex flex-col items-center justify-center">
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Missing Recipients</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>You need at least one recipient to add fields</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link to={`${relativePath.editorPath}`}>
|
||||||
|
<Trans>Add Recipients</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentEnvelopeItem !== null ? (
|
{currentEnvelopeItem !== null ? (
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
|
<PDFViewerKonvaLazy
|
||||||
|
renderer="editor"
|
||||||
|
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-32">
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
||||||
@ -127,31 +156,23 @@ export const EnvelopeEditorPageFields = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Section - Form Fields Panel */}
|
{/* Right Section - Form Fields Panel */}
|
||||||
{currentEnvelopeItem && (
|
{currentEnvelopeItem && envelope.recipients.length > 0 && (
|
||||||
<div className="sticky top-0 h-[calc(100vh-73px)] w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
|
<div className="bg-background border-border sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l py-4">
|
||||||
{/* Recipient selector section. */}
|
{/* Recipient selector section. */}
|
||||||
<section className="px-4">
|
<section className="px-4">
|
||||||
<h3 className="mb-2 text-sm font-semibold text-gray-900">
|
<h3 className="text-foreground mb-2 text-sm font-semibold">
|
||||||
<Trans>Selected Recipient</Trans>
|
<Trans>Selected Recipient</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{envelope.recipients.length === 0 ? (
|
<RecipientSelector
|
||||||
<Alert variant="warning">
|
selectedRecipient={editorFields.selectedRecipient}
|
||||||
<AlertDescription>
|
onSelectedRecipientChange={(recipient) =>
|
||||||
<Trans>You need at least one recipient to add fields</Trans>
|
editorFields.setSelectedRecipient(recipient.id)
|
||||||
</AlertDescription>
|
}
|
||||||
</Alert>
|
recipients={envelope.recipients}
|
||||||
) : (
|
className="w-full"
|
||||||
<RecipientSelector
|
align="end"
|
||||||
selectedRecipient={editorFields.selectedRecipient}
|
/>
|
||||||
onSelectedRecipientChange={(recipient) =>
|
|
||||||
editorFields.setSelectedRecipient(recipient.id)
|
|
||||||
}
|
|
||||||
recipients={envelope.recipients}
|
|
||||||
className="w-full"
|
|
||||||
align="end"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editorFields.selectedRecipient &&
|
{editorFields.selectedRecipient &&
|
||||||
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
|
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
|
||||||
@ -170,7 +191,7 @@ export const EnvelopeEditorPageFields = () => {
|
|||||||
|
|
||||||
{/* Add fields section. */}
|
{/* Add fields section. */}
|
||||||
<section className="px-4">
|
<section className="px-4">
|
||||||
<h3 className="mb-2 text-sm font-semibold text-gray-900">
|
<h3 className="text-foreground mb-2 text-sm font-semibold">
|
||||||
<Trans>Add Fields</Trans>
|
<Trans>Add Fields</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -182,7 +203,7 @@ export const EnvelopeEditorPageFields = () => {
|
|||||||
|
|
||||||
{/* Field details section. */}
|
{/* Field details section. */}
|
||||||
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
|
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
|
||||||
{selectedField && selectedField.type !== FieldType.SIGNATURE && (
|
{selectedField && (
|
||||||
<section>
|
<section>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
|
|
||||||
@ -192,6 +213,12 @@ export const EnvelopeEditorPageFields = () => {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{match(selectedField.type)
|
{match(selectedField.type)
|
||||||
|
.with(FieldType.SIGNATURE, () => (
|
||||||
|
<EditorFieldSignatureForm
|
||||||
|
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
|
||||||
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
.with(FieldType.CHECKBOX, () => (
|
.with(FieldType.CHECKBOX, () => (
|
||||||
<EditorFieldCheckboxForm
|
<EditorFieldCheckboxForm
|
||||||
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
|
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
|
||||||
@ -13,7 +13,6 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
@ -22,8 +21,8 @@ import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribu
|
|||||||
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
||||||
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
|
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
|
||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||||
|
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
|
||||||
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
import { TemplateDirectLinkBadge } from '../template/template-direct-link-badge';
|
import { TemplateDirectLinkBadge } from '../template/template-direct-link-badge';
|
||||||
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||||
@ -31,30 +30,34 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
|||||||
export default function EnvelopeEditorHeader() {
|
export default function EnvelopeEditorHeader() {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const {
|
||||||
|
envelope,
|
||||||
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError } =
|
isDocument,
|
||||||
useCurrentEnvelopeEditor();
|
isTemplate,
|
||||||
|
updateEnvelope,
|
||||||
// Todo: Envelopes this probably won't work with embed? Maybe hide the back items when no team?
|
autosaveError,
|
||||||
|
relativePath,
|
||||||
const rootPath = isDocument ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
editorFields,
|
||||||
|
} = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="w-full border-b border-gray-200 bg-white px-6 py-3">
|
<nav className="bg-background border-border w-full border-b px-4 py-3 md:px-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Link to="/">
|
<Link to="/">
|
||||||
<BrandingLogo className="h-6 w-auto" />
|
<BrandingLogo className="h-6 w-auto" />
|
||||||
</Link>
|
</Link>
|
||||||
<Separator orientation="vertical" className="h-6" />
|
<Separator orientation="vertical" className="h-6" />
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<EnvelopeItemTitleInput
|
<EnvelopeItemTitleInput
|
||||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||||
value={envelope.title}
|
value={envelope.title}
|
||||||
onChange={(title) => {
|
onChange={(title) => {
|
||||||
updateEnvelope({
|
updateEnvelope({
|
||||||
title,
|
data: {
|
||||||
|
title,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder={t`Envelope Title`}
|
placeholder={t`Envelope Title`}
|
||||||
@ -131,6 +134,8 @@ export default function EnvelopeEditorHeader() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
|
||||||
|
|
||||||
<EnvelopeEditorSettingsDialog
|
<EnvelopeEditorSettingsDialog
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
@ -142,7 +147,11 @@ export default function EnvelopeEditorHeader() {
|
|||||||
{isDocument && (
|
{isDocument && (
|
||||||
<>
|
<>
|
||||||
<EnvelopeDistributeDialog
|
<EnvelopeDistributeDialog
|
||||||
envelope={envelope}
|
envelope={{
|
||||||
|
...envelope,
|
||||||
|
fields: editorFields.localFields,
|
||||||
|
}}
|
||||||
|
documentRootPath={relativePath.documentRootPath}
|
||||||
trigger={
|
trigger={
|
||||||
<Button size="sm">
|
<Button size="sm">
|
||||||
<SendIcon className="mr-2 h-4 w-4" />
|
<SendIcon className="mr-2 h-4 w-4" />
|
||||||
@ -165,10 +174,11 @@ export default function EnvelopeEditorHeader() {
|
|||||||
|
|
||||||
{isTemplate && (
|
{isTemplate && (
|
||||||
<TemplateUseDialog
|
<TemplateUseDialog
|
||||||
|
envelopeId={envelope.id}
|
||||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||||
templateSigningOrder={envelope.documentMeta?.signingOrder}
|
templateSigningOrder={envelope.documentMeta?.signingOrder}
|
||||||
recipients={envelope.recipients}
|
recipients={envelope.recipients}
|
||||||
documentRootPath={rootPath}
|
documentRootPath={relativePath.documentRootPath}
|
||||||
trigger={
|
trigger={
|
||||||
<Button size="sm">
|
<Button size="sm">
|
||||||
<Trans>Use Template</Trans>
|
<Trans>Use Template</Trans>
|
||||||
|
|||||||
@ -1,176 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
|
||||||
|
|
||||||
import Konva from 'konva';
|
|
||||||
import type { Layer } from 'konva/lib/Layer';
|
|
||||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
|
||||||
import { usePageContext } from 'react-pdf';
|
|
||||||
|
|
||||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
|
||||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
|
||||||
|
|
||||||
export default function EnvelopeEditorPagePreviewRenderer() {
|
|
||||||
const pageContext = usePageContext();
|
|
||||||
|
|
||||||
if (!pageContext) {
|
|
||||||
throw new Error('Unable to find Page context.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { _className, page, rotate, scale } = pageContext;
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
|
||||||
|
|
||||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
|
||||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const stage = useRef<Konva.Stage | null>(null);
|
|
||||||
const pageLayer = useRef<Layer | null>(null);
|
|
||||||
|
|
||||||
const viewport = useMemo(
|
|
||||||
() => page.getViewport({ scale, rotation: rotate }),
|
|
||||||
[page, rotate, scale],
|
|
||||||
);
|
|
||||||
|
|
||||||
const localPageFields = useMemo(
|
|
||||||
() =>
|
|
||||||
editorFields.localFields.filter(
|
|
||||||
(field) =>
|
|
||||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
|
||||||
),
|
|
||||||
[editorFields.localFields, pageContext.pageNumber],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Custom renderer from Konva examples.
|
|
||||||
useEffect(
|
|
||||||
function drawPageOnCanvas() {
|
|
||||||
if (!page) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { current: canvas } = canvasElement;
|
|
||||||
const { current: container } = konvaContainer;
|
|
||||||
|
|
||||||
if (!canvas || !container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderContext: RenderParameters = {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
|
||||||
viewport,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancellable = page.render(renderContext);
|
|
||||||
const runningTask = cancellable;
|
|
||||||
|
|
||||||
cancellable.promise.catch(() => {
|
|
||||||
// Intentionally empty
|
|
||||||
});
|
|
||||||
|
|
||||||
void cancellable.promise.then(() => {
|
|
||||||
createPageCanvas(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
runningTask.cancel();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[page, viewport],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderFieldOnLayer = (field: TLocalField) => {
|
|
||||||
if (!pageLayer.current) {
|
|
||||||
console.error('Layer not loaded yet');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderField({
|
|
||||||
pageLayer: pageLayer.current,
|
|
||||||
field: {
|
|
||||||
renderId: field.formId,
|
|
||||||
...field,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: field.fieldMeta,
|
|
||||||
},
|
|
||||||
pageWidth: viewport.width,
|
|
||||||
pageHeight: viewport.height,
|
|
||||||
color: getRecipientColorKey(field.recipientId),
|
|
||||||
editable: false,
|
|
||||||
mode: 'export',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
|
||||||
*/
|
|
||||||
const createPageCanvas = (container: HTMLDivElement) => {
|
|
||||||
stage.current = new Konva.Stage({
|
|
||||||
container,
|
|
||||||
width: viewport.width,
|
|
||||||
height: viewport.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the main layer for interactive elements.
|
|
||||||
pageLayer.current = new Konva.Layer();
|
|
||||||
stage.current?.add(pageLayer.current);
|
|
||||||
|
|
||||||
// Render the fields.
|
|
||||||
for (const field of localPageFields) {
|
|
||||||
renderFieldOnLayer(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render fields when they are added or removed from the localFields.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pageLayer.current || !stage.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If doesn't exist in localFields, destroy it since it's been deleted.
|
|
||||||
pageLayer.current.find('Group').forEach((group) => {
|
|
||||||
if (
|
|
||||||
group.name() === 'field-group' &&
|
|
||||||
!localPageFields.some((field) => field.formId === group.id())
|
|
||||||
) {
|
|
||||||
console.log('Field removed, removing from canvas');
|
|
||||||
group.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// If it exists, rerender.
|
|
||||||
localPageFields.forEach((field) => {
|
|
||||||
console.log('Field created/updated, rendering on canvas');
|
|
||||||
renderFieldOnLayer(field);
|
|
||||||
});
|
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
|
||||||
}, [localPageFields]);
|
|
||||||
|
|
||||||
if (!currentEnvelopeItem) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
|
||||||
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
|
||||||
|
|
||||||
<canvas
|
|
||||||
className={`${_className}__canvas z-0`}
|
|
||||||
height={viewport.height}
|
|
||||||
ref={canvasElement}
|
|
||||||
width={viewport.width}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
import { lazy, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { FileTextIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
|
||||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
|
||||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
|
||||||
|
|
||||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
|
||||||
|
|
||||||
const EnvelopeEditorPagePreviewRenderer = lazy(
|
|
||||||
async () => import('./envelope-editor-page-preview-renderer'),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const EnvelopeEditorPagePreview = () => {
|
|
||||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
|
||||||
|
|
||||||
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
|
||||||
'recipient',
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the selected recipient to the first recipient in the envelope.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex h-full">
|
|
||||||
<div className="flex w-full flex-col">
|
|
||||||
{/* Horizontal envelope item selector */}
|
|
||||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
|
||||||
|
|
||||||
{/* Document View */}
|
|
||||||
<div className="mt-4 flex flex-col items-center justify-center">
|
|
||||||
<Alert variant="warning" className="mb-4 max-w-[800px]">
|
|
||||||
<AlertTitle>
|
|
||||||
<Trans>Preview Mode</Trans>
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
<Trans>Preview what the signed document will look like with placeholder data</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{currentEnvelopeItem !== null ? (
|
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorPagePreviewRenderer} />
|
|
||||||
) : (
|
|
||||||
<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">
|
|
||||||
<Trans>No documents found</Trans>
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
|
||||||
<Trans>Please upload a document to continue</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Section - Form Fields Panel */}
|
|
||||||
{currentEnvelopeItem && false && (
|
|
||||||
<div className="sticky top-0 h-[calc(100vh-73px)] w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
|
|
||||||
{/* Add fields section. */}
|
|
||||||
<section className="px-4">
|
|
||||||
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
|
|
||||||
<Trans>Preivew Mode</Trans>
|
|
||||||
</h3> */}
|
|
||||||
|
|
||||||
<Alert variant="neutral">
|
|
||||||
<AlertTitle>
|
|
||||||
<Trans>Preview Mode</Trans>
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
<Trans>Preview what the signed document will look like with placeholder data</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{/* <Alert variant="neutral">
|
|
||||||
<RadioGroup
|
|
||||||
className="gap-y-1"
|
|
||||||
value={selectedPreviewMode}
|
|
||||||
onValueChange={(value) => setSelectedPreviewMode(value as 'recipient' | 'signed')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<RadioGroupItem
|
|
||||||
id="document-signed-preview"
|
|
||||||
className="pointer-events-none h-3 w-3"
|
|
||||||
value="signed"
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor="document-signed-preview"
|
|
||||||
className="text-foreground ml-1.5 text-xs font-normal"
|
|
||||||
>
|
|
||||||
<Trans>Document Signed Preview</Trans>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center">
|
|
||||||
<RadioGroupItem
|
|
||||||
id="recipient-preview"
|
|
||||||
className="pointer-events-none h-3 w-3"
|
|
||||||
value="recipient"
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor="recipient-preview"
|
|
||||||
className="text-foreground ml-1.5 text-xs font-normal"
|
|
||||||
>
|
|
||||||
<Trans>Recipient Preview</Trans>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div>Preview what a recipient will see</div>
|
|
||||||
|
|
||||||
<div>Preview the signed document</div> */}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{false && (
|
|
||||||
<AnimateGenericFadeInOut key={selectedPreviewMode}>
|
|
||||||
{selectedPreviewMode === 'recipient' && (
|
|
||||||
<>
|
|
||||||
<Separator className="my-4" />
|
|
||||||
|
|
||||||
{/* Recipient selector section. */}
|
|
||||||
<section className="px-4">
|
|
||||||
<h3 className="mb-2 text-sm font-semibold text-gray-900">
|
|
||||||
<Trans>Selected Recipient</Trans>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<RecipientSelector
|
|
||||||
selectedRecipient={editorFields.selectedRecipient}
|
|
||||||
onSelectedRecipientChange={(recipient) =>
|
|
||||||
editorFields.setSelectedRecipient(recipient.id)
|
|
||||||
}
|
|
||||||
recipients={envelope.recipients}
|
|
||||||
className="w-full"
|
|
||||||
align="end"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimateGenericFadeInOut>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -0,0 +1,336 @@
|
|||||||
|
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { faker } from '@faker-js/faker/locale/en';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
import { FileTextIcon } from 'lucide-react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
|
import {
|
||||||
|
EnvelopeRenderProvider,
|
||||||
|
useCurrentEnvelopeRender,
|
||||||
|
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
|
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
|
||||||
|
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||||
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
|
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||||
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
|
|
||||||
|
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||||
|
|
||||||
|
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
|
||||||
|
|
||||||
|
// Todo: Envelopes - Dynamically import faker
|
||||||
|
export const EnvelopeEditorPreviewPage = () => {
|
||||||
|
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
|
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
||||||
|
'recipient',
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldsWithPlaceholders = useMemo(() => {
|
||||||
|
return fields.map((field) => {
|
||||||
|
const fieldMeta = ZFieldAndMetaSchema.parse(field);
|
||||||
|
|
||||||
|
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Error('Recipient not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
faker.seed(recipient.id);
|
||||||
|
|
||||||
|
const recipientName = recipient.name || faker.person.fullName();
|
||||||
|
const recipientEmail = recipient.email || faker.internet.email();
|
||||||
|
|
||||||
|
faker.seed(recipient.id + field.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
inserted: true,
|
||||||
|
...match(fieldMeta)
|
||||||
|
.with({ type: FieldType.TEXT }, ({ fieldMeta }) => {
|
||||||
|
let text = fieldMeta?.text || faker.lorem.words(5);
|
||||||
|
|
||||||
|
if (fieldMeta?.characterLimit) {
|
||||||
|
text = text.slice(0, fieldMeta?.characterLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customText: text,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.NUMBER }, ({ fieldMeta }) => {
|
||||||
|
let number = fieldMeta?.value ?? '';
|
||||||
|
|
||||||
|
if (number === '') {
|
||||||
|
number = faker.number
|
||||||
|
.int({
|
||||||
|
min: fieldMeta?.minValue ?? 0,
|
||||||
|
max: fieldMeta?.maxValue ?? 1000,
|
||||||
|
})
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customText: number,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.DATE }, () => {
|
||||||
|
const date = extractFieldInsertionValues({
|
||||||
|
fieldValue: {
|
||||||
|
type: FieldType.DATE,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
field,
|
||||||
|
documentMeta: envelope.documentMeta,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
customText: date.customText,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.EMAIL }, () => {
|
||||||
|
return {
|
||||||
|
customText: recipientEmail,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.NAME }, () => {
|
||||||
|
return {
|
||||||
|
customText: recipientName,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.INITIALS }, () => {
|
||||||
|
return {
|
||||||
|
customText: extractInitials(recipientName),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.RADIO }, ({ fieldMeta }) => {
|
||||||
|
const values = fieldMeta?.values ?? [];
|
||||||
|
|
||||||
|
if (values.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let customText = '';
|
||||||
|
|
||||||
|
const preselectedValue = values.findIndex((value) => value.checked);
|
||||||
|
|
||||||
|
if (preselectedValue !== -1) {
|
||||||
|
customText = preselectedValue.toString();
|
||||||
|
} else {
|
||||||
|
const randomIndex = faker.number.int({ min: 0, max: values.length - 1 });
|
||||||
|
customText = randomIndex.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customText,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.CHECKBOX }, ({ fieldMeta }) => {
|
||||||
|
let checkedValues: number[] = [];
|
||||||
|
|
||||||
|
const values = fieldMeta?.values ?? [];
|
||||||
|
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
if (value.checked) {
|
||||||
|
checkedValues.push(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (checkedValues.length === 0 && values.length > 0) {
|
||||||
|
const numberOfValues = fieldMeta?.validationLength || 1;
|
||||||
|
|
||||||
|
checkedValues = Array.from({ length: numberOfValues }, (_, index) => index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customText: toCheckboxCustomText(checkedValues),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.DROPDOWN }, ({ fieldMeta }) => {
|
||||||
|
const values = fieldMeta?.values ?? [];
|
||||||
|
|
||||||
|
let customText = fieldMeta?.defaultValue || '';
|
||||||
|
|
||||||
|
if (!customText && values.length > 0) {
|
||||||
|
const randomIndex = faker.number.int({ min: 0, max: values.length - 1 });
|
||||||
|
customText = values[randomIndex].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customText,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.SIGNATURE }, () => {
|
||||||
|
return {
|
||||||
|
customText: '',
|
||||||
|
signature: {
|
||||||
|
signatureImageAsBase64: '',
|
||||||
|
typedSignature: recipientName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.FREE_SIGNATURE }, () => {
|
||||||
|
return {
|
||||||
|
customText: '',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.exhaustive(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [fields, envelope, envelope.recipients, envelope.documentMeta]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the selected recipient to the first recipient in the envelope.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Override the parent renderer provider so we can inject custom fields.
|
||||||
|
return (
|
||||||
|
<EnvelopeRenderProvider
|
||||||
|
envelope={envelope}
|
||||||
|
token={undefined}
|
||||||
|
fields={fieldsWithPlaceholders}
|
||||||
|
recipients={envelope.recipients}
|
||||||
|
overrideSettings={{
|
||||||
|
mode: 'export',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative flex h-full">
|
||||||
|
<div className="flex w-full flex-col overflow-y-auto">
|
||||||
|
{/* Horizontal envelope item selector */}
|
||||||
|
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||||
|
|
||||||
|
{/* Document View */}
|
||||||
|
<div className="mt-4 flex flex-col items-center justify-center">
|
||||||
|
<Alert variant="warning" className="mb-4 max-w-[800px]">
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Preview Mode</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>Preview what the signed document will look like with placeholder data</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{currentEnvelopeItem !== null ? (
|
||||||
|
<PDFViewerKonvaLazy
|
||||||
|
renderer="editor"
|
||||||
|
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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">
|
||||||
|
<Trans>No documents found</Trans>
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
<Trans>Please upload a document to continue</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Section - Form Fields Panel */}
|
||||||
|
{currentEnvelopeItem && false && (
|
||||||
|
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
|
||||||
|
{/* Add fields section. */}
|
||||||
|
<section className="px-4">
|
||||||
|
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
|
||||||
|
<Trans>Preivew Mode</Trans>
|
||||||
|
</h3> */}
|
||||||
|
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Preview Mode</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>
|
||||||
|
Preview what the signed document will look like with placeholder data
|
||||||
|
</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* <Alert variant="neutral">
|
||||||
|
<RadioGroup
|
||||||
|
className="gap-y-1"
|
||||||
|
value={selectedPreviewMode}
|
||||||
|
onValueChange={(value) => setSelectedPreviewMode(value as 'recipient' | 'signed')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<RadioGroupItem
|
||||||
|
id="document-signed-preview"
|
||||||
|
className="pointer-events-none h-3 w-3"
|
||||||
|
value="signed"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="document-signed-preview"
|
||||||
|
className="text-foreground ml-1.5 text-xs font-normal"
|
||||||
|
>
|
||||||
|
<Trans>Document Signed Preview</Trans>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<RadioGroupItem
|
||||||
|
id="recipient-preview"
|
||||||
|
className="pointer-events-none h-3 w-3"
|
||||||
|
value="recipient"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="recipient-preview"
|
||||||
|
className="text-foreground ml-1.5 text-xs font-normal"
|
||||||
|
>
|
||||||
|
<Trans>Recipient Preview</Trans>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div>Preview what a recipient will see</div>
|
||||||
|
|
||||||
|
<div>Preview the signed document</div> */}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{false && (
|
||||||
|
<AnimateGenericFadeInOut key={selectedPreviewMode}>
|
||||||
|
{selectedPreviewMode === 'recipient' && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
{/* Recipient selector section. */}
|
||||||
|
<section className="px-4">
|
||||||
|
<h3 className="mb-2 text-sm font-semibold text-gray-900">
|
||||||
|
<Trans>Selected Recipient</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<RecipientSelector
|
||||||
|
selectedRecipient={editorFields.selectedRecipient}
|
||||||
|
onSelectedRecipientChange={(recipient) =>
|
||||||
|
editorFields.setSelectedRecipient(recipient.id)
|
||||||
|
}
|
||||||
|
recipients={envelope.recipients}
|
||||||
|
className="w-full"
|
||||||
|
align="end"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimateGenericFadeInOut>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</EnvelopeRenderProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -14,7 +14,7 @@ import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
|
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
|
||||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||||
import { prop, sortBy } from 'remeda';
|
import { isDeepEqual, prop, sortBy } from 'remeda';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
@ -75,7 +75,6 @@ const ZEnvelopeRecipientsForm = z.object({
|
|||||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
// Todo: Envelopes - These aren't synced to the server
|
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||||
allowDictateNextSigner: z.boolean().default(false),
|
allowDictateNextSigner: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
@ -83,7 +82,7 @@ const ZEnvelopeRecipientsForm = z.object({
|
|||||||
type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>;
|
type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>;
|
||||||
|
|
||||||
export const EnvelopeEditorRecipientForm = () => {
|
export const EnvelopeEditorRecipientForm = () => {
|
||||||
const { envelope, setRecipientsDebounced } = useCurrentEnvelopeEditor();
|
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
@ -149,8 +148,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always show advanced settings if any recipient has auth options.
|
const recipientHasAuthSettings = useMemo(() => {
|
||||||
const alwaysShowAdvancedSettings = useMemo(() => {
|
|
||||||
const recipientHasAuthOptions = recipients.find((recipient) => {
|
const recipientHasAuthOptions = recipients.find((recipient) => {
|
||||||
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
||||||
|
|
||||||
@ -166,7 +164,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
|
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
|
||||||
}, [recipients, form]);
|
}, [recipients, form]);
|
||||||
|
|
||||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
|
const [showAdvancedSettings, setShowAdvancedSettings] = useState(recipientHasAuthSettings);
|
||||||
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
|
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -451,6 +449,8 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void form.trigger();
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||||
@ -460,15 +460,77 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
|
const formValueSigners = formValues.signers || [];
|
||||||
|
|
||||||
if (validatedFormValues.success) {
|
// Remove the last signer if it's empty.
|
||||||
console.log('validatedFormValues', validatedFormValues);
|
const nonEmptyRecipients = formValueSigners.filter((signer, i) => {
|
||||||
|
if (i === formValueSigners.length - 1 && signer.email === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
setRecipientsDebounced(validatedFormValues.data.signers);
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// Todo: Envelopes - Need to save the other data as well
|
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
|
||||||
// setEnvelope
|
...formValues,
|
||||||
|
signers: nonEmptyRecipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validatedFormValues.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = validatedFormValues;
|
||||||
|
|
||||||
|
// Weird edge case where the whole envelope is created via API
|
||||||
|
// with no signing order. If they come to this page it will show an error
|
||||||
|
// since they aren't equal and the recipient is no longer editable.
|
||||||
|
const envelopeRecipients = data.signers.map((recipient) => {
|
||||||
|
if (!canRecipientBeModified(recipient.id)) {
|
||||||
|
return {
|
||||||
|
...recipient,
|
||||||
|
signingOrder: recipient.signingOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return recipient;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasSigningOrderChanged = envelope.documentMeta.signingOrder !== data.signingOrder;
|
||||||
|
const hasAllowDictateNextSignerChanged =
|
||||||
|
envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner;
|
||||||
|
|
||||||
|
const hasSignersChanged =
|
||||||
|
envelopeRecipients.length !== recipients.length ||
|
||||||
|
envelopeRecipients.some((signer) => {
|
||||||
|
const recipient = recipients.find((recipient) => recipient.id === signer.id);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signerActionAuth = signer.actionAuth;
|
||||||
|
const recipientActionAuth = recipient.authOptions?.actionAuth || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
signer.email !== recipient.email ||
|
||||||
|
signer.name !== recipient.name ||
|
||||||
|
signer.role !== recipient.role ||
|
||||||
|
signer.signingOrder !== recipient.signingOrder ||
|
||||||
|
!isDeepEqual(signerActionAuth, recipientActionAuth)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasSignersChanged) {
|
||||||
|
setRecipientsDebounced(envelopeRecipients);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) {
|
||||||
|
updateEnvelope({
|
||||||
|
meta: {
|
||||||
|
signingOrder: validatedFormValues.data.signingOrder,
|
||||||
|
allowDictateNextSigner: validatedFormValues.data.allowDictateNextSigner,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [formValues]);
|
}, [formValues]);
|
||||||
|
|
||||||
@ -508,18 +570,17 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-gray-50/80 p-4">
|
<div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4">
|
||||||
{!alwaysShowAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
|
{organisation.organisationClaim.flags.cfr21 && (
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="showAdvancedRecipientSettings"
|
id="showAdvancedRecipientSettings"
|
||||||
className="h-5 w-5"
|
|
||||||
checked={showAdvancedSettings}
|
checked={showAdvancedSettings}
|
||||||
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
|
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
className="text-muted-foreground ml-2 text-sm"
|
className="ml-2 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
htmlFor="showAdvancedRecipientSettings"
|
htmlFor="showAdvancedRecipientSettings"
|
||||||
>
|
>
|
||||||
<Trans>Show advanced settings</Trans>
|
<Trans>Show advanced settings</Trans>
|
||||||
@ -678,44 +739,171 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
<motion.fieldset
|
<motion.fieldset
|
||||||
data-native-id={signer.id}
|
data-native-id={signer.id}
|
||||||
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
|
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
|
||||||
className={cn('grid grid-cols-10 items-end gap-2 pb-2', {
|
className={cn('pb-2', {
|
||||||
'border-b pt-2': showAdvancedSettings,
|
'border-b pb-4':
|
||||||
'grid-cols-12 pr-3': isSigningOrderSequential,
|
showAdvancedSettings && index !== signers.length - 1,
|
||||||
|
'pt-2': showAdvancedSettings && index === 0,
|
||||||
|
'pr-3': isSigningOrderSequential,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isSigningOrderSequential && (
|
<div className="flex flex-row items-center gap-x-2">
|
||||||
|
{isSigningOrderSequential && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`signers.${index}.signingOrder`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn(
|
||||||
|
'mt-auto flex items-center gap-x-1 space-y-0',
|
||||||
|
{
|
||||||
|
'mb-6':
|
||||||
|
form.formState.errors.signers?.[index] &&
|
||||||
|
!form.formState.errors.signers[index]?.signingOrder,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
max={signers.length}
|
||||||
|
data-testid="signing-order-input"
|
||||||
|
className={cn(
|
||||||
|
'w-10 text-center',
|
||||||
|
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e);
|
||||||
|
handleSigningOrderChange(index, e.target.value);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
field.onBlur();
|
||||||
|
handleSigningOrderChange(index, e.target.value);
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
snapshot.isDragging ||
|
||||||
|
isSubmitting ||
|
||||||
|
!canRecipientBeModified(signer.id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`signers.${index}.signingOrder`}
|
name={`signers.${index}.email`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem
|
<FormItem
|
||||||
className={cn(
|
className={cn('relative w-full', {
|
||||||
'col-span-1 mt-auto flex items-center gap-x-1 space-y-0',
|
'mb-6':
|
||||||
{
|
form.formState.errors.signers?.[index] &&
|
||||||
'mb-6':
|
!form.formState.errors.signers[index]?.email,
|
||||||
form.formState.errors.signers?.[index] &&
|
})}
|
||||||
!form.formState.errors.signers[index]?.signingOrder,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
|
{!showAdvancedSettings && index === 0 && (
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<RecipientAutoCompleteInput
|
||||||
type="number"
|
type="email"
|
||||||
max={signers.length}
|
placeholder={t`Email`}
|
||||||
data-testid="signing-order-input"
|
value={field.value}
|
||||||
className={cn(
|
disabled={
|
||||||
'w-full text-center',
|
snapshot.isDragging ||
|
||||||
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
isSubmitting ||
|
||||||
)}
|
!canRecipientBeModified(signer.id)
|
||||||
{...field}
|
}
|
||||||
onChange={(e) => {
|
options={recipientSuggestions}
|
||||||
field.onChange(e);
|
onSelect={(suggestion) =>
|
||||||
handleSigningOrderChange(index, e.target.value);
|
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||||
|
}
|
||||||
|
onSearchQueryChange={(query) => {
|
||||||
|
field.onChange(query);
|
||||||
|
setRecipientSearchQuery(query);
|
||||||
}}
|
}}
|
||||||
onBlur={(e) => {
|
loading={isLoading}
|
||||||
field.onBlur();
|
data-testid="signer-email-input"
|
||||||
handleSigningOrderChange(index, e.target.value);
|
maxLength={254}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`signers.${index}.name`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn('w-full', {
|
||||||
|
'mb-6':
|
||||||
|
form.formState.errors.signers?.[index] &&
|
||||||
|
!form.formState.errors.signers[index]?.name,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{!showAdvancedSettings && index === 0 && (
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<RecipientAutoCompleteInput
|
||||||
|
type="text"
|
||||||
|
placeholder={t`Name`}
|
||||||
|
{...field}
|
||||||
|
disabled={
|
||||||
|
snapshot.isDragging ||
|
||||||
|
isSubmitting ||
|
||||||
|
!canRecipientBeModified(signer.id)
|
||||||
|
}
|
||||||
|
options={recipientSuggestions}
|
||||||
|
onSelect={(suggestion) =>
|
||||||
|
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||||
|
}
|
||||||
|
onSearchQueryChange={(query) => {
|
||||||
|
field.onChange(query);
|
||||||
|
setRecipientSearchQuery(query);
|
||||||
|
}}
|
||||||
|
loading={isLoading}
|
||||||
|
maxLength={255}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`signers.${index}.role`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn('mt-auto w-fit', {
|
||||||
|
'mb-6':
|
||||||
|
form.formState.errors.signers?.[index] &&
|
||||||
|
!form.formState.errors.signers[index]?.role,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<RecipientRoleSelect
|
||||||
|
{...field}
|
||||||
|
isAssistantEnabled={isSigningOrderSequential}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
handleRoleChange(index, value as RecipientRole);
|
||||||
|
field.onChange(value);
|
||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
snapshot.isDragging ||
|
snapshot.isDragging ||
|
||||||
@ -724,106 +912,29 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
<Button
|
||||||
control={form.control}
|
variant="ghost"
|
||||||
name={`signers.${index}.email`}
|
className={cn('mt-auto px-2', {
|
||||||
render={({ field }) => (
|
'mb-6': form.formState.errors.signers?.[index],
|
||||||
<FormItem
|
})}
|
||||||
className={cn('relative', {
|
data-testid="remove-signer-button"
|
||||||
'mb-6':
|
disabled={
|
||||||
form.formState.errors.signers?.[index] &&
|
snapshot.isDragging ||
|
||||||
!form.formState.errors.signers[index]?.email,
|
isSubmitting ||
|
||||||
'col-span-4': !showAdvancedSettings,
|
!canRecipientBeModified(signer.id) ||
|
||||||
'col-span-5': showAdvancedSettings,
|
signers.length === 1
|
||||||
})}
|
}
|
||||||
>
|
onClick={() => onRemoveSigner(index)}
|
||||||
{!showAdvancedSettings && index === 0 && (
|
>
|
||||||
<FormLabel required>
|
<TrashIcon className="h-4 w-4" />
|
||||||
<Trans>Email</Trans>
|
</Button>
|
||||||
</FormLabel>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<RecipientAutoCompleteInput
|
|
||||||
type="email"
|
|
||||||
placeholder={t`Email`}
|
|
||||||
value={field.value}
|
|
||||||
disabled={
|
|
||||||
snapshot.isDragging ||
|
|
||||||
isSubmitting ||
|
|
||||||
!canRecipientBeModified(signer.id)
|
|
||||||
}
|
|
||||||
options={recipientSuggestions}
|
|
||||||
onSelect={(suggestion) =>
|
|
||||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
|
||||||
}
|
|
||||||
onSearchQueryChange={(query) => {
|
|
||||||
field.onChange(query);
|
|
||||||
setRecipientSearchQuery(query);
|
|
||||||
}}
|
|
||||||
loading={isLoading}
|
|
||||||
data-testid="signer-email-input"
|
|
||||||
maxLength={254}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`signers.${index}.name`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem
|
|
||||||
className={cn({
|
|
||||||
'mb-6':
|
|
||||||
form.formState.errors.signers?.[index] &&
|
|
||||||
!form.formState.errors.signers[index]?.name,
|
|
||||||
'col-span-4': !showAdvancedSettings,
|
|
||||||
'col-span-5': showAdvancedSettings,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{!showAdvancedSettings && index === 0 && (
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<RecipientAutoCompleteInput
|
|
||||||
type="text"
|
|
||||||
placeholder={t`Name`}
|
|
||||||
{...field}
|
|
||||||
disabled={
|
|
||||||
snapshot.isDragging ||
|
|
||||||
isSubmitting ||
|
|
||||||
!canRecipientBeModified(signer.id)
|
|
||||||
}
|
|
||||||
options={recipientSuggestions}
|
|
||||||
onSelect={(suggestion) =>
|
|
||||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
|
||||||
}
|
|
||||||
onSearchQueryChange={(query) => {
|
|
||||||
field.onChange(query);
|
|
||||||
setRecipientSearchQuery(query);
|
|
||||||
}}
|
|
||||||
loading={isLoading}
|
|
||||||
maxLength={255}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showAdvancedSettings &&
|
{showAdvancedSettings &&
|
||||||
organisation.organisationClaim.flags.cfr21 && (
|
organisation.organisationClaim.flags.cfr21 && (
|
||||||
@ -832,11 +943,11 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
name={`signers.${index}.actionAuth`}
|
name={`signers.${index}.actionAuth`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem
|
<FormItem
|
||||||
className={cn('col-span-8', {
|
className={cn('mt-2 w-full', {
|
||||||
'mb-6':
|
'mb-6':
|
||||||
form.formState.errors.signers?.[index] &&
|
form.formState.errors.signers?.[index] &&
|
||||||
!form.formState.errors.signers[index]?.actionAuth,
|
!form.formState.errors.signers[index]?.actionAuth,
|
||||||
'col-span-10': isSigningOrderSequential,
|
'pl-6': isSigningOrderSequential,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@ -856,60 +967,6 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="col-span-2 flex gap-x-2">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`signers.${index}.role`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem
|
|
||||||
className={cn('mt-auto', {
|
|
||||||
'mb-6':
|
|
||||||
form.formState.errors.signers?.[index] &&
|
|
||||||
!form.formState.errors.signers[index]?.role,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<RecipientRoleSelect
|
|
||||||
{...field}
|
|
||||||
isAssistantEnabled={isSigningOrderSequential}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
handleRoleChange(index, value as RecipientRole);
|
|
||||||
}}
|
|
||||||
disabled={
|
|
||||||
snapshot.isDragging ||
|
|
||||||
isSubmitting ||
|
|
||||||
!canRecipientBeModified(signer.id)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'mt-auto inline-flex h-10 w-10 items-center justify-center hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
|
||||||
{
|
|
||||||
'mb-6': form.formState.errors.signers?.[index],
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
data-testid="remove-signer-button"
|
|
||||||
disabled={
|
|
||||||
snapshot.isDragging ||
|
|
||||||
isSubmitting ||
|
|
||||||
!canRecipientBeModified(signer.id) ||
|
|
||||||
signers.length === 1
|
|
||||||
}
|
|
||||||
onClick={() => onRemoveSigner(index)}
|
|
||||||
>
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.fieldset>
|
</motion.fieldset>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -174,7 +174,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { envelope } = useCurrentEnvelopeEditor();
|
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
@ -186,14 +186,12 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
documentAuth: envelope.authOptions,
|
documentAuth: envelope.authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<TAddSettingsFormSchema>({
|
const createDefaultValues = () => {
|
||||||
resolver: zodResolver(ZAddSettingsFormSchema),
|
return {
|
||||||
defaultValues: {
|
externalId: envelope.externalId || '',
|
||||||
externalId: envelope.externalId || '', // Todo: String or undefined?
|
|
||||||
visibility: envelope.visibility || '',
|
visibility: envelope.visibility || '',
|
||||||
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
|
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
|
||||||
globalActionAuth: documentAuthOption?.globalActionAuth || [],
|
globalActionAuth: documentAuthOption?.globalActionAuth || [],
|
||||||
|
|
||||||
meta: {
|
meta: {
|
||||||
subject: envelope.documentMeta.subject ?? '',
|
subject: envelope.documentMeta.subject ?? '',
|
||||||
message: envelope.documentMeta.message ?? '',
|
message: envelope.documentMeta.message ?? '',
|
||||||
@ -210,12 +208,14 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
|
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
|
||||||
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
|
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm<TAddSettingsFormSchema>({
|
||||||
|
resolver: zodResolver(ZAddSettingsFormSchema),
|
||||||
|
defaultValues: createDefaultValues(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation();
|
|
||||||
|
|
||||||
// Todo: Envelopes - Extract into provider.
|
|
||||||
const envelopeHasBeenSent =
|
const envelopeHasBeenSent =
|
||||||
envelope.type === EnvelopeType.DOCUMENT &&
|
envelope.type === EnvelopeType.DOCUMENT &&
|
||||||
envelope.recipients.some((recipient) => recipient.sendStatus === SendStatus.SENT);
|
envelope.recipients.some((recipient) => recipient.sendStatus === SendStatus.SENT);
|
||||||
@ -230,7 +230,6 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
|
|
||||||
const emails = emailData?.data || [];
|
const emails = emailData?.data || [];
|
||||||
|
|
||||||
// Todo: Envelopes this doesn't make sense (look at previous)
|
|
||||||
const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility);
|
const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility);
|
||||||
|
|
||||||
const onFormSubmit = async (data: TAddSettingsFormSchema) => {
|
const onFormSubmit = async (data: TAddSettingsFormSchema) => {
|
||||||
@ -241,9 +240,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
.safeParse(data.globalAccessAuth);
|
.safeParse(data.globalAccessAuth);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateEnvelope({
|
await updateEnvelopeAsync({
|
||||||
envelopeId: envelope.id,
|
|
||||||
envelopeType: envelope.type,
|
|
||||||
data: {
|
data: {
|
||||||
externalId: data.externalId || null,
|
externalId: data.externalId || null,
|
||||||
visibility: data.visibility,
|
visibility: data.visibility,
|
||||||
@ -298,12 +295,10 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset();
|
form.reset(createDefaultValues());
|
||||||
setActiveTab('general');
|
setActiveTab('general');
|
||||||
}, [open, form]);
|
}, [open, form]);
|
||||||
|
|
||||||
// Todo: Envelopes - Show error indicator if error is in different tab.
|
|
||||||
|
|
||||||
const selectedTab = tabs.find((tab) => tab.id === activeTab);
|
const selectedTab = tabs.find((tab) => tab.id === activeTab);
|
||||||
|
|
||||||
if (!selectedTab) {
|
if (!selectedTab) {
|
||||||
@ -326,7 +321,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
|
|
||||||
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
|
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
|
||||||
{/* Sidebar. */}
|
{/* Sidebar. */}
|
||||||
<div className="flex w-80 flex-col border-r bg-gray-50">
|
<div className="bg-accent/20 flex w-80 flex-col border-r">
|
||||||
<DialogHeader className="p-6 pb-4">
|
<DialogHeader className="p-6 pb-4">
|
||||||
<DialogTitle>Document Settings</DialogTitle>
|
<DialogTitle>Document Settings</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@ -358,7 +353,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset
|
<fieldset
|
||||||
className="flex min-h-[45rem] w-full flex-col space-y-6 px-6 pt-6"
|
className="flex h-[45rem] max-h-[calc(100vh-14rem)] w-full flex-col space-y-6 overflow-y-auto px-6 pt-6"
|
||||||
disabled={form.formState.isSubmitting}
|
disabled={form.formState.isSubmitting}
|
||||||
key={activeTab}
|
key={activeTab}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -7,16 +7,20 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
|||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import {
|
import {
|
||||||
useCurrentEnvelopeEditor,
|
useCurrentEnvelopeEditor,
|
||||||
useDebounceFunction,
|
useDebounceFunction,
|
||||||
} from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
} from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -26,9 +30,9 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@documenso/ui/primitives/card';
|
} from '@documenso/ui/primitives/card';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
|
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
|
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
|
||||||
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||||
@ -41,11 +45,13 @@ type LocalFile = {
|
|||||||
isError: boolean;
|
isError: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EnvelopeEditorPageUpload = () => {
|
export const EnvelopeEditorUploadPage = () => {
|
||||||
const team = useCurrentTeam();
|
const organisation = useCurrentOrganisation();
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
const { envelope, setLocalEnvelope } = useCurrentEnvelopeEditor();
|
const { t } = useLingui();
|
||||||
|
const { envelope, setLocalEnvelope, relativePath } = useCurrentEnvelopeEditor();
|
||||||
|
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
|
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
|
||||||
envelope.envelopeItems
|
envelope.envelopeItems
|
||||||
@ -61,8 +67,8 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
|
|
||||||
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
|
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
|
||||||
trpc.envelope.item.createMany.useMutation({
|
trpc.envelope.item.createMany.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: ({ data }) => {
|
||||||
const createdEnvelopes = data.createdEnvelopeItems.filter(
|
const createdEnvelopes = data.filter(
|
||||||
(item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id),
|
(item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -73,10 +79,10 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: updateEnvelopeItems } = trpc.envelope.item.updateMany.useMutation({
|
const { mutateAsync: updateEnvelopeItems } = trpc.envelope.item.updateMany.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: ({ data }) => {
|
||||||
setLocalEnvelope({
|
setLocalEnvelope({
|
||||||
envelopeItems: envelope.envelopeItems.map((originalItem) => {
|
envelopeItems: envelope.envelopeItems.map((originalItem) => {
|
||||||
const updatedItem = data.updatedEnvelopeItems.find((item) => item.id === originalItem.id);
|
const updatedItem = data.find((item) => item.id === originalItem.id);
|
||||||
|
|
||||||
if (updatedItem) {
|
if (updatedItem) {
|
||||||
return {
|
return {
|
||||||
@ -108,36 +114,19 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
|
|
||||||
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
|
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
|
||||||
|
|
||||||
const result = await Promise.all(
|
const payload = {
|
||||||
files.map(async (file, index) => {
|
|
||||||
try {
|
|
||||||
const response = await putPdfFile(file);
|
|
||||||
|
|
||||||
// Mark as uploaded (remove from uploading state)
|
|
||||||
return {
|
|
||||||
title: file.name,
|
|
||||||
documentDataId: response.id,
|
|
||||||
};
|
|
||||||
} catch (_error) {
|
|
||||||
setLocalFiles((prev) =>
|
|
||||||
prev.map((uploadingFile) =>
|
|
||||||
uploadingFile.id === newUploadingFiles[index].id
|
|
||||||
? { ...uploadingFile, isError: true, isUploading: false }
|
|
||||||
: uploadingFile,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const envelopeItemsToCreate = result.filter(
|
|
||||||
(item): item is { title: string; documentDataId: string } => item !== undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { createdEnvelopeItems } = await createEnvelopeItems({
|
|
||||||
envelopeId: envelope.id,
|
envelopeId: envelope.id,
|
||||||
items: envelopeItemsToCreate,
|
} satisfies TCreateEnvelopeItemsPayload;
|
||||||
}).catch((error) => {
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append('files', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await createEnvelopeItems(formData).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
// Set error state on files in batch upload.
|
// Set error state on files in batch upload.
|
||||||
@ -159,7 +148,7 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return filteredFiles.concat(
|
return filteredFiles.concat(
|
||||||
createdEnvelopeItems.map((item) => ({
|
data.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
envelopeItemId: item.id,
|
envelopeItemId: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
@ -197,7 +186,6 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
debouncedUpdateEnvelopeItems(items);
|
debouncedUpdateEnvelopeItems(items);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Todo: Envelopes - Sync into envelopes data
|
|
||||||
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
|
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
|
||||||
void updateEnvelopeItems({
|
void updateEnvelopeItems({
|
||||||
envelopeId: envelope.id,
|
envelopeId: envelope.id,
|
||||||
@ -220,12 +208,56 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
debouncedUpdateEnvelopeItems(newLocalFilesValue);
|
debouncedUpdateEnvelopeItems(newLocalFilesValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dropzoneDisabledMessage = useMemo(() => {
|
||||||
|
if (!canItemsBeModified) {
|
||||||
|
return msg`Cannot upload items after the document has been sent`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (organisation.subscription && remaining.documents === 0) {
|
||||||
|
return msg`Document upload disabled due to unpaid invoices`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maximumEnvelopeItemCount <= localFiles.length) {
|
||||||
|
return msg`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [localFiles.length, maximumEnvelopeItemCount, remaining.documents]);
|
||||||
|
|
||||||
|
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
||||||
|
const maxItemsReached = fileRejections.some((fileRejection) =>
|
||||||
|
fileRejection.errors.some((error) => error.code === DropzoneErrorCode.TooManyFiles),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (maxItemsReached) {
|
||||||
|
toast({
|
||||||
|
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
|
||||||
|
duration: 5000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Upload failed`,
|
||||||
|
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
||||||
|
duration: 5000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
||||||
<Card backdropBlur={false} className="border">
|
<Card backdropBlur={false} className="border">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>Documents</CardTitle>
|
<CardTitle>
|
||||||
<CardDescription>Add and configure multiple documents</CardDescription>
|
<Trans>Documents</Trans>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
<Trans>Add and configure multiple documents</Trans>
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -233,9 +265,11 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
allowMultiple
|
allowMultiple
|
||||||
className="pb-4 pt-6"
|
className="pb-4 pt-6"
|
||||||
disabled={!canItemsBeModified}
|
disabled={dropzoneDisabledMessage !== null}
|
||||||
disabledMessage={msg`Cannot upload items after the document has been sent`}
|
disabledMessage={dropzoneDisabledMessage || undefined}
|
||||||
disabledHeading={msg`Upload disabled`}
|
disabledHeading={msg`Upload disabled`}
|
||||||
|
maxFiles={maximumEnvelopeItemCount - localFiles.length}
|
||||||
|
onDropRejected={onFileDropRejected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Uploaded Files List */}
|
{/* Uploaded Files List */}
|
||||||
@ -256,7 +290,7 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
style={provided.draggableProps.style}
|
style={provided.draggableProps.style}
|
||||||
className={`flex items-center justify-between rounded-lg bg-gray-50 p-3 transition-shadow ${
|
className={`bg-accent/50 flex items-center justify-between rounded-lg p-3 transition-shadow ${
|
||||||
snapshot.isDragging ? 'shadow-md' : ''
|
snapshot.isDragging ? 'shadow-md' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -282,7 +316,7 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
<p className="text-sm font-medium">{localFile.title}</p>
|
<p className="text-sm font-medium">{localFile.title}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-muted-foreground text-xs">
|
||||||
{localFile.isUploading ? (
|
{localFile.isUploading ? (
|
||||||
<Trans>Uploading</Trans>
|
<Trans>Uploading</Trans>
|
||||||
) : localFile.isError ? (
|
) : localFile.isError ? (
|
||||||
@ -295,7 +329,7 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{localFile.isUploading && (
|
{localFile.isUploading && (
|
||||||
<div className="flex h-6 w-10 items-center justify-center">
|
<div className="flex h-6 w-10 items-center justify-center">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-gray-500" />
|
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -338,7 +372,7 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to={`/t/${team.url}/documents/${envelope.id}/edit?step=addFields`}>
|
<Link to={`${relativePath.editorPath}?step=addFields`}>
|
||||||
<Trans>Add Fields</Trans>
|
<Trans>Add Fields</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@ -24,7 +24,6 @@ import {
|
|||||||
mapSecondaryIdToDocumentId,
|
mapSecondaryIdToDocumentId,
|
||||||
mapSecondaryIdToTemplateId,
|
mapSecondaryIdToTemplateId,
|
||||||
} from '@documenso/lib/utils/envelope';
|
} from '@documenso/lib/utils/envelope';
|
||||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
@ -32,17 +31,17 @@ import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
|||||||
|
|
||||||
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
||||||
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
|
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
|
||||||
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
||||||
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
||||||
import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog';
|
import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog';
|
||||||
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
||||||
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
|
import { EnvelopeEditorFieldsPage } from './envelope-editor-fields-page';
|
||||||
import EnvelopeEditorHeader from './envelope-editor-header';
|
import EnvelopeEditorHeader from './envelope-editor-header';
|
||||||
import { EnvelopeEditorPageFields } from './envelope-editor-page-fields';
|
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
|
||||||
import { EnvelopeEditorPagePreview } from './envelope-editor-page-preview';
|
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
|
||||||
import { EnvelopeEditorPageUpload } from './envelope-editor-page-upload';
|
|
||||||
|
|
||||||
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
||||||
|
|
||||||
@ -74,10 +73,16 @@ export default function EnvelopeEditor() {
|
|||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const { envelope, isDocument, isTemplate, isAutosaving, flushAutosave } =
|
const {
|
||||||
useCurrentEnvelopeEditor();
|
envelope,
|
||||||
|
isDocument,
|
||||||
|
isTemplate,
|
||||||
|
isAutosaving,
|
||||||
|
flushAutosave,
|
||||||
|
relativePath,
|
||||||
|
editorFields,
|
||||||
|
} = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@ -100,13 +105,10 @@ export default function EnvelopeEditor() {
|
|||||||
return 'upload';
|
return 'upload';
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
|
||||||
const templatesPath = formatTemplatesPath(team.url);
|
|
||||||
|
|
||||||
const navigateToStep = (step: EnvelopeEditorStep) => {
|
const navigateToStep = (step: EnvelopeEditorStep) => {
|
||||||
setCurrentStep(step);
|
setCurrentStep(step);
|
||||||
|
|
||||||
flushAutosave();
|
void flushAutosave();
|
||||||
|
|
||||||
if (!isStepLoading && isAutosaving) {
|
if (!isStepLoading && isAutosaving) {
|
||||||
setIsStepLoading(true);
|
setIsStepLoading(true);
|
||||||
@ -128,6 +130,18 @@ export default function EnvelopeEditor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Watch the URL params and setStep if the step changes.
|
||||||
|
useEffect(() => {
|
||||||
|
const stepParam = searchParams.get('step') || envelopeEditorSteps[0].id;
|
||||||
|
|
||||||
|
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
|
||||||
|
|
||||||
|
if (foundStep && foundStep.id !== currentStep) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
navigateToStep(foundStep.id as EnvelopeEditorStep);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAutosaving) {
|
if (!isAutosaving) {
|
||||||
setIsStepLoading(false);
|
setIsStepLoading(false);
|
||||||
@ -138,20 +152,22 @@ export default function EnvelopeEditor() {
|
|||||||
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
|
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen bg-gray-50">
|
<div className="dark:bg-background h-screen w-screen bg-gray-50">
|
||||||
<EnvelopeEditorHeader />
|
<EnvelopeEditorHeader />
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex h-[calc(100vh-73px)] w-screen">
|
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||||
{/* Left Section - Step Navigation */}
|
{/* Left Section - Step Navigation */}
|
||||||
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-gray-200 bg-white py-4">
|
<div className="bg-background border-border flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4">
|
||||||
{/* Left section step selector. */}
|
{/* Left section step selector. */}
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<h3 className="flex items-end justify-between text-sm font-semibold text-gray-900">
|
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
|
||||||
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
||||||
|
|
||||||
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
|
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
|
||||||
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
<Trans context="The step counter">
|
||||||
|
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
||||||
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -176,15 +192,17 @@ export default function EnvelopeEditor() {
|
|||||||
key={step.id}
|
key={step.id}
|
||||||
className={`cursor-pointer rounded-lg p-3 transition-colors ${
|
className={`cursor-pointer rounded-lg p-3 transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'border border-green-200 bg-green-50'
|
? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
|
||||||
: 'border border-gray-200 hover:bg-gray-50'
|
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
|
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div
|
<div
|
||||||
className={`rounded border p-2 ${
|
className={`rounded border p-2 ${
|
||||||
isActive ? 'border-green-200 bg-green-50' : 'border-gray-100 bg-gray-100'
|
isActive
|
||||||
|
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
|
||||||
|
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
@ -194,12 +212,14 @@ export default function EnvelopeEditor() {
|
|||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className={`text-sm font-medium ${
|
className={`text-sm font-medium ${
|
||||||
isActive ? 'text-green-900' : 'text-gray-700'
|
isActive
|
||||||
|
? 'text-green-900 dark:text-green-400'
|
||||||
|
: 'text-foreground dark:text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(step.title)}
|
{t(step.title)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">{t(step.description)}</div>
|
<div className="text-muted-foreground text-xs">{t(step.description)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -212,12 +232,25 @@ export default function EnvelopeEditor() {
|
|||||||
|
|
||||||
{/* Quick Actions. */}
|
{/* Quick Actions. */}
|
||||||
<div className="space-y-3 px-4">
|
<div className="space-y-3 px-4">
|
||||||
<h4 className="text-sm font-semibold text-gray-900">
|
<h4 className="text-foreground text-sm font-semibold">
|
||||||
<Trans>Quick Actions</Trans>
|
<Trans>Quick Actions</Trans>
|
||||||
</h4>
|
</h4>
|
||||||
|
<EnvelopeEditorSettingsDialog
|
||||||
|
trigger={
|
||||||
|
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||||
|
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||||
|
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{isDocument && (
|
{isDocument && (
|
||||||
<EnvelopeDistributeDialog
|
<EnvelopeDistributeDialog
|
||||||
envelope={envelope}
|
envelope={{
|
||||||
|
...envelope,
|
||||||
|
fields: editorFields.localFields,
|
||||||
|
}}
|
||||||
|
documentRootPath={relativePath.documentRootPath}
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||||
<SendIcon className="mr-2 h-4 w-4" />
|
<SendIcon className="mr-2 h-4 w-4" />
|
||||||
@ -239,16 +272,6 @@ export default function EnvelopeEditor() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<EnvelopeEditorSettingsDialog
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
|
||||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
|
||||||
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Todo: Envelopes */}
|
|
||||||
{/* <Button variant="ghost" size="sm" className="w-full justify-start">
|
{/* <Button variant="ghost" size="sm" className="w-full justify-start">
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
Save as Template
|
Save as Template
|
||||||
@ -283,11 +306,17 @@ export default function EnvelopeEditor() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Todo: Allow selecting which document to download and/or the original */}
|
<EnvelopeDownloadDialog
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
envelopeId={envelope.id}
|
||||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
envelopeStatus={envelope.status}
|
||||||
<Trans>Download PDF</Trans>
|
envelopeItems={envelope.envelopeItems}
|
||||||
</Button>
|
trigger={
|
||||||
|
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||||
|
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Download PDF</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -309,7 +338,7 @@ export default function EnvelopeEditor() {
|
|||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
await navigate(documentsPath);
|
await navigate(relativePath.documentRootPath);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -318,7 +347,7 @@ export default function EnvelopeEditor() {
|
|||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
await navigate(templatesPath);
|
await navigate(relativePath.templateRootPath);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -326,7 +355,7 @@ export default function EnvelopeEditor() {
|
|||||||
{/* Footer of left sidebar. */}
|
{/* Footer of left sidebar. */}
|
||||||
<div className="mt-auto px-4">
|
<div className="mt-auto px-4">
|
||||||
<Button variant="ghost" className="w-full justify-start" asChild>
|
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||||
<Link to={isDocument ? documentsPath : templatesPath}>
|
<Link to={relativePath.basePath}>
|
||||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||||
{isDocument ? (
|
{isDocument ? (
|
||||||
<Trans>Return to documents</Trans>
|
<Trans>Return to documents</Trans>
|
||||||
@ -339,17 +368,14 @@ export default function EnvelopeEditor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content - Changes based on current step */}
|
{/* Main Content - Changes based on current step */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}>
|
||||||
<p>{isAutosaving ? 'Autosaving...' : 'Not autosaving'}</p>
|
{match({ currentStep, isStepLoading })
|
||||||
<AnimateGenericFadeInOut key={currentStep}>
|
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
|
||||||
{match({ currentStep, isStepLoading })
|
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
|
||||||
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
|
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
|
||||||
.with({ currentStep: 'upload' }, () => <EnvelopeEditorPageUpload />)
|
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
|
||||||
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorPageFields />)
|
.exhaustive()}
|
||||||
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPagePreview />)
|
</AnimateGenericFadeInOut>
|
||||||
.exhaustive()}
|
|
||||||
</AnimateGenericFadeInOut>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -20,16 +20,17 @@ export const EnvelopeItemSelector = ({
|
|||||||
}: EnvelopeItemSelectorProps) => {
|
}: EnvelopeItemSelectorProps) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`flex min-w-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
|
title={typeof primaryText === 'string' ? primaryText : undefined}
|
||||||
|
className={`flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-blue-200 bg-blue-50 text-blue-900'
|
? 'border-green-200 bg-green-50 text-green-900 dark:border-green-400/30 dark:bg-green-400/10 dark:text-green-400'
|
||||||
: 'border-gray-200 bg-gray-50 hover:bg-gray-100'
|
: 'border-border bg-muted/50 hover:bg-muted/70'
|
||||||
}`}
|
}`}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium ${
|
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-medium ${
|
||||||
isSelected ? 'bg-blue-100 text-blue-600' : 'bg-gray-200 text-gray-600'
|
isSelected ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{number}
|
{number}
|
||||||
@ -39,8 +40,8 @@ export const EnvelopeItemSelector = ({
|
|||||||
<div className="text-xs text-gray-500">{secondaryText}</div>
|
<div className="text-xs text-gray-500">{secondaryText}</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn('h-2 w-2 rounded-full', {
|
className={cn('h-2 w-2 flex-shrink-0 rounded-full', {
|
||||||
'bg-blue-500': isSelected,
|
'bg-green-500': isSelected,
|
||||||
})}
|
})}
|
||||||
></div>
|
></div>
|
||||||
</button>
|
</button>
|
||||||
@ -61,7 +62,7 @@ export const EnvelopeRendererFileSelector = ({
|
|||||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex h-fit space-x-2 overflow-x-auto p-4', className)}>
|
<div className={cn('flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4', className)}>
|
||||||
{envelopeItems.map((doc, i) => (
|
{envelopeItems.map((doc, i) => (
|
||||||
<EnvelopeItemSelector
|
<EnvelopeItemSelector
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
|
|||||||
@ -1,141 +1,127 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import Konva from 'konva';
|
import { type Recipient, SigningStatus } from '@prisma/client';
|
||||||
import type { Layer } from 'konva/lib/Layer';
|
import type Konva from 'konva';
|
||||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
|
||||||
import { usePageContext } from 'react-pdf';
|
|
||||||
|
|
||||||
|
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||||
|
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||||
|
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
|
||||||
|
|
||||||
|
type GenericLocalField = TEnvelope['fields'][number] & {
|
||||||
|
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
||||||
|
};
|
||||||
|
|
||||||
export default function EnvelopeGenericPageRenderer() {
|
export default function EnvelopeGenericPageRenderer() {
|
||||||
const pageContext = usePageContext();
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
if (!pageContext) {
|
const {
|
||||||
throw new Error('Unable to find Page context.');
|
currentEnvelopeItem,
|
||||||
}
|
fields,
|
||||||
|
recipients,
|
||||||
|
getRecipientColorKey,
|
||||||
|
setRenderError,
|
||||||
|
overrideSettings,
|
||||||
|
} = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const { _className, page, rotate, scale } = pageContext;
|
const {
|
||||||
|
stage,
|
||||||
|
pageLayer,
|
||||||
|
canvasElement,
|
||||||
|
konvaContainer,
|
||||||
|
pageContext,
|
||||||
|
scaledViewport,
|
||||||
|
unscaledViewport,
|
||||||
|
} = usePageRenderer(({ stage, pageLayer }) => {
|
||||||
|
createPageCanvas(stage, pageLayer);
|
||||||
|
});
|
||||||
|
|
||||||
if (!page) {
|
const { _className, scale } = pageContext;
|
||||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useLingui();
|
const localPageFields = useMemo((): GenericLocalField[] => {
|
||||||
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
return fields
|
||||||
|
.filter(
|
||||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
|
||||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const stage = useRef<Konva.Stage | null>(null);
|
|
||||||
const pageLayer = useRef<Layer | null>(null);
|
|
||||||
|
|
||||||
const viewport = useMemo(
|
|
||||||
() => page.getViewport({ scale, rotation: rotate }),
|
|
||||||
[page, rotate, scale],
|
|
||||||
);
|
|
||||||
|
|
||||||
const localPageFields = useMemo(
|
|
||||||
() =>
|
|
||||||
fields.filter(
|
|
||||||
(field) =>
|
(field) =>
|
||||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||||
),
|
)
|
||||||
[fields, pageContext.pageNumber],
|
.map((field) => {
|
||||||
);
|
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
|
||||||
|
|
||||||
// Custom renderer from Konva examples.
|
if (!recipient) {
|
||||||
useEffect(
|
throw new Error(`Recipient not found for field ${field.id}`);
|
||||||
function drawPageOnCanvas() {
|
}
|
||||||
if (!page) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { current: canvas } = canvasElement;
|
return {
|
||||||
const { current: container } = konvaContainer;
|
...field,
|
||||||
|
recipient,
|
||||||
if (!canvas || !container) {
|
};
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderContext: RenderParameters = {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
|
||||||
viewport,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancellable = page.render(renderContext);
|
|
||||||
const runningTask = cancellable;
|
|
||||||
|
|
||||||
cancellable.promise.catch(() => {
|
|
||||||
// Intentionally empty
|
|
||||||
});
|
});
|
||||||
|
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
|
||||||
|
|
||||||
void cancellable.promise.then(() => {
|
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
|
||||||
createPageCanvas(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
runningTask.cancel();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[page, viewport],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
|
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
console.error('Layer not loaded yet');
|
console.error('Layer not loaded yet');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { recipient } = field;
|
||||||
|
|
||||||
|
const fieldTranslations = getClientSideFieldTranslations(i18n);
|
||||||
|
|
||||||
|
const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted;
|
||||||
|
|
||||||
renderField({
|
renderField({
|
||||||
|
scale,
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
field: {
|
field: {
|
||||||
renderId: field.id.toString(),
|
renderId: field.id.toString(),
|
||||||
...field,
|
...field,
|
||||||
customText: '',
|
|
||||||
width: Number(field.width),
|
width: Number(field.width),
|
||||||
height: Number(field.height),
|
height: Number(field.height),
|
||||||
positionX: Number(field.positionX),
|
positionX: Number(field.positionX),
|
||||||
positionY: Number(field.positionY),
|
positionY: Number(field.positionY),
|
||||||
inserted: false,
|
customText: isInserted ? field.customText : '',
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
|
signature: {
|
||||||
|
signatureImageAsBase64: '',
|
||||||
|
typedSignature: fieldTranslations.SIGNATURE,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
pageWidth: viewport.width,
|
translations: fieldTranslations,
|
||||||
pageHeight: viewport.height,
|
pageWidth: unscaledViewport.width,
|
||||||
// color: getRecipientColorKey(field.recipientId),
|
pageHeight: unscaledViewport.height,
|
||||||
color: 'purple', // Todo
|
color: getRecipientColorKey(field.recipientId),
|
||||||
editable: false,
|
editable: false,
|
||||||
mode: 'sign',
|
mode: overrideSettings?.mode ?? 'sign',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderFieldOnLayer = (field: GenericLocalField) => {
|
||||||
|
try {
|
||||||
|
unsafeRenderFieldOnLayer(field);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setRenderError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
* Initialize the Konva page canvas and all fields and interactions.
|
||||||
*/
|
*/
|
||||||
const createPageCanvas = (container: HTMLDivElement) => {
|
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||||
stage.current = new Konva.Stage({
|
|
||||||
container,
|
|
||||||
width: viewport.width,
|
|
||||||
height: viewport.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the main layer for interactive elements.
|
|
||||||
pageLayer.current = new Konva.Layer();
|
|
||||||
stage.current?.add(pageLayer.current);
|
|
||||||
|
|
||||||
// Render the fields.
|
// Render the fields.
|
||||||
for (const field of localPageFields) {
|
for (const field of localPageFields) {
|
||||||
renderFieldOnLayer(field);
|
renderFieldOnLayer(field);
|
||||||
}
|
}
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
currentPageLayer.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render fields when they are added or removed from the localFields.
|
* Render fields when they are added or removed
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pageLayer.current || !stage.current) {
|
if (!pageLayer.current || !stage.current) {
|
||||||
@ -148,14 +134,12 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
group.name() === 'field-group' &&
|
group.name() === 'field-group' &&
|
||||||
!localPageFields.some((field) => field.id.toString() === group.id())
|
!localPageFields.some((field) => field.id.toString() === group.id())
|
||||||
) {
|
) {
|
||||||
console.log('Field removed, removing from canvas');
|
|
||||||
group.destroy();
|
group.destroy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// If it exists, rerender.
|
// If it exists, rerender.
|
||||||
localPageFields.forEach((field) => {
|
localPageFields.forEach((field) => {
|
||||||
console.log('Field created/updated, rendering on canvas');
|
|
||||||
renderFieldOnLayer(field);
|
renderFieldOnLayer(field);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -167,14 +151,29 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
<div
|
||||||
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
className="relative w-full"
|
||||||
|
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||||
|
>
|
||||||
|
{overrideSettings?.showRecipientTooltip &&
|
||||||
|
localPageFields.map((field) => (
|
||||||
|
<EnvelopeRecipientFieldTooltip
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
showFieldStatus={overrideSettings?.showRecipientSigningStatus}
|
||||||
|
showRecipientTooltip={overrideSettings?.showRecipientTooltip}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* The element Konva will inject it's canvas into. */}
|
||||||
|
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||||
|
|
||||||
|
{/* Canvas the PDF will be rendered on. */}
|
||||||
<canvas
|
<canvas
|
||||||
className={`${_className}__canvas z-0`}
|
className={`${_className}__canvas z-0`}
|
||||||
height={viewport.height}
|
|
||||||
ref={canvasElement}
|
ref={canvasElement}
|
||||||
width={viewport.width}
|
height={scaledViewport.height}
|
||||||
|
width={scaledViewport.width}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,17 +1,33 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { FieldType } from '@prisma/client';
|
import { FieldType, RecipientRole } from '@prisma/client';
|
||||||
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||||
|
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
|
|
||||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||||
|
|
||||||
export default function EnvelopeSignerForm() {
|
export default function EnvelopeSignerForm() {
|
||||||
const { fullName, signature, setFullName, setSignature, envelope, recipientFields } =
|
const {
|
||||||
useRequiredEnvelopeSigningContext();
|
fullName,
|
||||||
|
signature,
|
||||||
|
setFullName,
|
||||||
|
setSignature,
|
||||||
|
envelope,
|
||||||
|
recipientFields,
|
||||||
|
recipient,
|
||||||
|
assistantFields,
|
||||||
|
assistantRecipients,
|
||||||
|
selectedAssistantRecipient,
|
||||||
|
setSelectedAssistantRecipientId,
|
||||||
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
const hasSignatureField = useMemo(() => {
|
const hasSignatureField = useMemo(() => {
|
||||||
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
|
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||||
@ -19,6 +35,63 @@ export default function EnvelopeSignerForm() {
|
|||||||
|
|
||||||
const isSubmitting = false;
|
const isSubmitting = false;
|
||||||
|
|
||||||
|
if (recipient.role === RecipientRole.VIEWER) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||||
|
return (
|
||||||
|
<fieldset className="embed--DocumentWidgetForm dark:bg-background border-border rounded-2xl sm:border sm:p-3">
|
||||||
|
<RadioGroup
|
||||||
|
className="gap-0 space-y-2 shadow-none sm:space-y-3"
|
||||||
|
value={selectedAssistantRecipient?.id?.toString()}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedAssistantRecipientId(Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{assistantRecipients
|
||||||
|
.filter((r) => r.fields.length > 0)
|
||||||
|
.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem
|
||||||
|
id={r.id.toString()}
|
||||||
|
value={r.id.toString()}
|
||||||
|
className="after:absolute after:inset-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grow gap-1">
|
||||||
|
<Label className="inline-flex items-start" htmlFor={r.id.toString()}>
|
||||||
|
{r.name}
|
||||||
|
|
||||||
|
{r.id === recipient.id && (
|
||||||
|
<span className="text-muted-foreground ml-2">
|
||||||
|
<Trans>(You)</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">{r.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs leading-[inherit]">
|
||||||
|
<Plural
|
||||||
|
value={assistantFields.filter((field) => field.recipientId === r.id).length}
|
||||||
|
one="# field"
|
||||||
|
other="# fields"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset disabled={isSubmitting} className="flex flex-1 flex-col gap-4">
|
<fieldset disabled={isSubmitting} className="flex flex-1 flex-col gap-4">
|
||||||
<div className="flex flex-1 flex-col gap-y-4">
|
<div className="flex flex-1 flex-col gap-y-4">
|
||||||
@ -32,7 +105,8 @@ export default function EnvelopeSignerForm() {
|
|||||||
id="full-name"
|
id="full-name"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
value={fullName}
|
value={fullName}
|
||||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
disabled={isNameLocked}
|
||||||
|
onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,131 +1,142 @@
|
|||||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { Link, useNavigate } from 'react-router';
|
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||||
|
import { BanIcon, DownloadCloudIcon } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
|
||||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
|
||||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
|
|
||||||
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||||
|
|
||||||
import { DocumentSigningCompleteDialog } from '../document-signing/document-signing-complete-dialog';
|
import { BrandingLogoIcon } from '../branding-logo-icon';
|
||||||
|
import { DocumentSigningRejectDialog } from '../document-signing/document-signing-reject-dialog';
|
||||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||||
|
import { EnvelopeSignerCompleteDialog } from './envelope-signing-complete-dialog';
|
||||||
|
|
||||||
export const EnvelopeSignerHeader = () => {
|
export const EnvelopeSignerHeader = () => {
|
||||||
const { t } = useLingui();
|
const { envelopeData, envelope, recipientFieldsRemaining, recipient } =
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const analytics = useAnalytics();
|
|
||||||
|
|
||||||
const { envelope, setShowPendingFieldTooltip, recipientFieldsRemaining, recipient } =
|
|
||||||
useRequiredEnvelopeSigningContext();
|
useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
|
||||||
|
|
||||||
const {
|
|
||||||
mutateAsync: completeDocument,
|
|
||||||
isPending,
|
|
||||||
isSuccess,
|
|
||||||
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
|
||||||
|
|
||||||
const handleOnNextFieldClick = () => {
|
|
||||||
const nextField = recipientFieldsRemaining[0];
|
|
||||||
|
|
||||||
if (!nextField) {
|
|
||||||
setShowPendingFieldTooltip(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextField.envelopeItemId !== currentEnvelopeItem?.id) {
|
|
||||||
setCurrentEnvelopeItem(nextField.envelopeItemId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldTooltip = document.querySelector(`#field-tooltip`);
|
|
||||||
|
|
||||||
if (fieldTooltip) {
|
|
||||||
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOnCompleteClick = async (
|
|
||||||
nextSigner?: { name: string; email: string },
|
|
||||||
accessAuthOptions?: TRecipientAccessAuth,
|
|
||||||
) => {
|
|
||||||
const payload = {
|
|
||||||
token: recipient.token,
|
|
||||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
|
||||||
authOptions: accessAuthOptions,
|
|
||||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
await completeDocument(payload);
|
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
|
||||||
signerId: recipient.id,
|
|
||||||
documentId: envelope.id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (envelope.documentMeta.redirectUrl) {
|
|
||||||
window.location.href = envelope.documentMeta.redirectUrl;
|
|
||||||
} else {
|
|
||||||
await navigate(`/sign/${recipient.token}/complete`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="w-full border-b border-gray-200 bg-white px-6 py-3">
|
<nav className="embed--DocumentWidgetHeader bg-background border-border max-w-screen flex flex-row justify-between border-b px-4 py-3 md:px-6">
|
||||||
<div className="flex items-center justify-between">
|
{/* Left side - Logo and title */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex min-w-0 flex-1 items-center space-x-2 md:w-auto md:flex-none">
|
||||||
<Link to="/">
|
<Link to="/" className="flex-shrink-0">
|
||||||
<BrandingLogo className="h-6 w-auto" />
|
{envelopeData.settings.brandingEnabled && envelopeData.settings.brandingLogo ? (
|
||||||
</Link>
|
<img
|
||||||
<Separator orientation="vertical" className="h-6" />
|
src={`/api/branding/logo/team/${envelope.teamId}`}
|
||||||
|
alt={`${envelope.team.name}'s Logo`}
|
||||||
<div className="flex items-center space-x-2">
|
className="h-6 w-auto"
|
||||||
<h1 className="whitespace-nowrap text-sm font-medium text-gray-600">
|
|
||||||
{envelope.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<Badge variant="secondary">
|
|
||||||
<Trans>Approver</Trans>
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
|
|
||||||
<Plural
|
|
||||||
one="1 Field Remaining"
|
|
||||||
other="# Fields Remaining"
|
|
||||||
value={recipientFieldsRemaining.length}
|
|
||||||
/>
|
/>
|
||||||
</p>
|
) : (
|
||||||
|
<>
|
||||||
|
<BrandingLogo className="hidden h-6 w-auto md:block" />
|
||||||
|
<BrandingLogoIcon className="h-6 w-auto md:hidden" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
<h1
|
||||||
isSubmitting={isPending}
|
title={envelope.title}
|
||||||
onSignatureComplete={handleOnCompleteClick}
|
className="text-foreground min-w-0 truncate text-base font-semibold md:hidden"
|
||||||
documentTitle={envelope.title}
|
>
|
||||||
fields={recipientFieldsRemaining}
|
{envelope.title}
|
||||||
fieldsValidated={handleOnNextFieldClick}
|
</h1>
|
||||||
recipient={recipient}
|
|
||||||
// Todo: Envelopes
|
<Separator orientation="vertical" className="hidden h-6 md:block" />
|
||||||
allowDictateNextSigner={envelope.documentMeta.allowDictateNextSigner}
|
|
||||||
// defaultNextSigner={
|
<div className="hidden items-center space-x-2 md:flex">
|
||||||
// nextRecipient
|
<h1 className="text-foreground whitespace-nowrap text-sm font-medium">
|
||||||
// ? { name: nextRecipient.name, email: nextRecipient.email }
|
{envelope.title}
|
||||||
// : undefined
|
</h1>
|
||||||
// }
|
|
||||||
// Todo: Envelopes - use
|
<Badge>
|
||||||
// buttonSize="sm"
|
{match(recipient.role)
|
||||||
/>
|
.with(RecipientRole.VIEWER, () => <Trans>Viewer</Trans>)
|
||||||
|
.with(RecipientRole.SIGNER, () => <Trans>Signer</Trans>)
|
||||||
|
.with(RecipientRole.APPROVER, () => <Trans>Approver</Trans>)
|
||||||
|
.with(RecipientRole.ASSISTANT, () => <Trans>Assistant</Trans>)
|
||||||
|
.otherwise(() => null)}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Desktop content */}
|
||||||
|
<div className="hidden items-center space-x-2 lg:flex">
|
||||||
|
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
|
||||||
|
<Plural
|
||||||
|
one="1 Field Remaining"
|
||||||
|
other="# Fields Remaining"
|
||||||
|
value={recipientFieldsRemaining.length}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<EnvelopeSignerCompleteDialog />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Actions button */}
|
||||||
|
<div className="flex-shrink-0 lg:hidden">
|
||||||
|
<MobileDropdownMenu />
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MobileDropdownMenu = () => {
|
||||||
|
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
const { allowDocumentRejection } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Trans>Actions</Trans>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<EnvelopeDownloadDialog
|
||||||
|
envelopeId={envelope.id}
|
||||||
|
envelopeStatus={envelope.status}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
|
token={recipient.token}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||||
|
<div>
|
||||||
|
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Download PDF</Trans>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection !== false && (
|
||||||
|
<DocumentSigningRejectDialog
|
||||||
|
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
|
token={recipient.token}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||||
|
<div>
|
||||||
|
<BanIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Reject</Trans>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -1,22 +1,30 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { type Field, FieldType } from '@prisma/client';
|
import { type Field, FieldType, RecipientRole, type Signature } from '@prisma/client';
|
||||||
import Konva from 'konva';
|
import type Konva from 'konva';
|
||||||
import type { Layer } from 'konva/lib/Layer';
|
|
||||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
|
||||||
import { usePageContext } from 'react-pdf';
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
||||||
|
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||||
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
||||||
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
||||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
|
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
||||||
|
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
|
||||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
|
import { handleCheckboxFieldClick } from '~/utils/field-signing/checkbox-field';
|
||||||
import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field';
|
import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field';
|
||||||
import { handleEmailFieldClick } from '~/utils/field-signing/email-field';
|
import { handleEmailFieldClick } from '~/utils/field-signing/email-field';
|
||||||
import { handleInitialsFieldClick } from '~/utils/field-signing/initial-field';
|
import { handleInitialsFieldClick } from '~/utils/field-signing/initial-field';
|
||||||
@ -25,102 +33,65 @@ import { handleNumberFieldClick } from '~/utils/field-signing/number-field';
|
|||||||
import { handleSignatureFieldClick } from '~/utils/field-signing/signature-field';
|
import { handleSignatureFieldClick } from '~/utils/field-signing/signature-field';
|
||||||
import { handleTextFieldClick } from '~/utils/field-signing/text-field';
|
import { handleTextFieldClick } from '~/utils/field-signing/text-field';
|
||||||
|
|
||||||
|
import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
|
||||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||||
|
|
||||||
export default function EnvelopeSignerPageRenderer() {
|
export default function EnvelopeSignerPageRenderer() {
|
||||||
const pageContext = usePageContext();
|
const { t, i18n } = useLingui();
|
||||||
|
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||||
|
const { sessionData } = useOptionalSession();
|
||||||
|
|
||||||
if (!pageContext) {
|
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||||
throw new Error('Unable to find Page context.');
|
const { toast } = useToast();
|
||||||
}
|
|
||||||
|
|
||||||
const { _className, page, rotate, scale } = pageContext;
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
envelopeData,
|
envelopeData,
|
||||||
|
recipient,
|
||||||
recipientFields,
|
recipientFields,
|
||||||
recipientFieldsRemaining,
|
recipientFieldsRemaining,
|
||||||
showPendingFieldTooltip,
|
showPendingFieldTooltip,
|
||||||
signField,
|
signField: signFieldInternal,
|
||||||
email,
|
email,
|
||||||
setEmail,
|
setEmail,
|
||||||
fullName,
|
fullName,
|
||||||
setFullName,
|
setFullName,
|
||||||
signature,
|
signature,
|
||||||
setSignature,
|
setSignature,
|
||||||
|
selectedAssistantRecipientFields,
|
||||||
|
selectedAssistantRecipient,
|
||||||
|
isDirectTemplate,
|
||||||
} = useRequiredEnvelopeSigningContext();
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
console.log({ fullName });
|
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
|
const {
|
||||||
|
stage,
|
||||||
|
pageLayer,
|
||||||
|
canvasElement,
|
||||||
|
konvaContainer,
|
||||||
|
pageContext,
|
||||||
|
scaledViewport,
|
||||||
|
unscaledViewport,
|
||||||
|
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
||||||
|
|
||||||
|
const { _className, scale } = pageContext;
|
||||||
|
|
||||||
const { envelope } = envelopeData;
|
const { envelope } = envelopeData;
|
||||||
|
|
||||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
const localPageFields = useMemo(() => {
|
||||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
let fieldsToRender = recipientFields;
|
||||||
|
|
||||||
const stage = useRef<Konva.Stage | null>(null);
|
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||||
const pageLayer = useRef<Layer | null>(null);
|
fieldsToRender = selectedAssistantRecipientFields;
|
||||||
|
}
|
||||||
|
|
||||||
const viewport = useMemo(
|
return fieldsToRender.filter(
|
||||||
() => page.getViewport({ scale, rotation: rotate }),
|
(field) =>
|
||||||
[page, rotate, scale],
|
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||||
);
|
);
|
||||||
|
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
|
||||||
|
|
||||||
const localPageFields = useMemo(
|
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||||
() =>
|
|
||||||
recipientFields.filter(
|
|
||||||
(field) =>
|
|
||||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
|
||||||
),
|
|
||||||
[recipientFields, pageContext.pageNumber],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Custom renderer from Konva examples.
|
|
||||||
useEffect(
|
|
||||||
function drawPageOnCanvas() {
|
|
||||||
if (!page) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { current: canvas } = canvasElement;
|
|
||||||
const { current: container } = konvaContainer;
|
|
||||||
|
|
||||||
if (!canvas || !container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderContext: RenderParameters = {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
|
||||||
viewport,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancellable = page.render(renderContext);
|
|
||||||
const runningTask = cancellable;
|
|
||||||
|
|
||||||
cancellable.promise.catch(() => {
|
|
||||||
// Intentionally empty
|
|
||||||
});
|
|
||||||
|
|
||||||
void cancellable.promise.then(() => {
|
|
||||||
createPageCanvas(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
runningTask.cancel();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[page, viewport],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderFieldOnLayer = (unparsedField: Field) => {
|
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
console.error('Layer not loaded yet');
|
console.error('Layer not loaded yet');
|
||||||
return;
|
return;
|
||||||
@ -137,6 +108,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { fieldGroup } = renderField({
|
const { fieldGroup } = renderField({
|
||||||
|
scale,
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
field: {
|
field: {
|
||||||
renderId: fieldToRender.id.toString(),
|
renderId: fieldToRender.id.toString(),
|
||||||
@ -145,9 +117,11 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
height: Number(fieldToRender.height),
|
height: Number(fieldToRender.height),
|
||||||
positionX: Number(fieldToRender.positionX),
|
positionX: Number(fieldToRender.positionX),
|
||||||
positionY: Number(fieldToRender.positionY),
|
positionY: Number(fieldToRender.positionY),
|
||||||
|
signature: unparsedField.signature,
|
||||||
},
|
},
|
||||||
pageWidth: viewport.width,
|
translations: getClientSideFieldTranslations(i18n),
|
||||||
pageHeight: viewport.height,
|
pageWidth: unscaledViewport.width,
|
||||||
|
pageHeight: unscaledViewport.height,
|
||||||
color,
|
color,
|
||||||
mode: 'sign',
|
mode: 'sign',
|
||||||
});
|
});
|
||||||
@ -158,19 +132,35 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
|
|
||||||
const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect();
|
const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect();
|
||||||
|
|
||||||
const foundField = recipientFields.find((f) => f.id === unparsedField.id);
|
const foundField = localPageFields.find((f) => f.id === unparsedField.id);
|
||||||
const foundLoadingGroup = currentTarget.findOne('.loading-spinner-group');
|
const foundLoadingGroup = currentTarget.findOne('.loading-spinner-group');
|
||||||
|
|
||||||
if (!foundField || foundLoadingGroup || foundField.fieldMeta?.readOnly) {
|
if (!foundField || foundLoadingGroup || foundField.fieldMeta?.readOnly) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadingSpinnerGroup = createSpinner({
|
let localEmail: string | null = email;
|
||||||
fieldWidth,
|
let localFullName: string | null = fullName;
|
||||||
fieldHeight,
|
let placeholderEmail: string | null = null;
|
||||||
});
|
|
||||||
|
|
||||||
fieldGroup.add(loadingSpinnerGroup);
|
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||||
|
localEmail = selectedAssistantRecipient?.email || null;
|
||||||
|
localFullName = selectedAssistantRecipient?.name || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allows us let the user set a different email than their current logged in email.
|
||||||
|
if (isDirectTemplate) {
|
||||||
|
placeholderEmail = sessionData?.user?.email || email || recipient.email;
|
||||||
|
|
||||||
|
if (!placeholderEmail || placeholderEmail === DIRECT_TEMPLATE_RECIPIENT_EMAIL) {
|
||||||
|
placeholderEmail = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingSpinnerGroup = createSpinner({
|
||||||
|
fieldWidth: fieldWidth / scale,
|
||||||
|
fieldHeight: fieldHeight / scale,
|
||||||
|
});
|
||||||
|
|
||||||
const parsedFoundField = ZFullFieldSchema.parse(foundField);
|
const parsedFoundField = ZFullFieldSchema.parse(foundField);
|
||||||
|
|
||||||
@ -179,34 +169,39 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
* CHECKBOX FIELD.
|
* CHECKBOX FIELD.
|
||||||
*/
|
*/
|
||||||
.with({ type: FieldType.CHECKBOX }, (field) => {
|
.with({ type: FieldType.CHECKBOX }, (field) => {
|
||||||
const { fieldMeta } = field;
|
const clickedCheckboxIndex = Number(target.getAttr('internalCheckboxIndex'));
|
||||||
|
|
||||||
const { values } = fieldMeta;
|
if (Number.isNaN(clickedCheckboxIndex)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const checkedValues = (values || [])
|
handleCheckboxFieldClick({ field, clickedCheckboxIndex })
|
||||||
.map((v) => ({
|
.then(async (payload) => {
|
||||||
...v,
|
if (payload) {
|
||||||
checked: v.id === target.getAttr('internalCheckboxId') ? !v.checked : v.checked,
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
}))
|
await signField(field.id, payload);
|
||||||
.filter((v) => v.checked);
|
}
|
||||||
|
})
|
||||||
void signField(field.id, {
|
.finally(() => {
|
||||||
type: FieldType.CHECKBOX,
|
loadingSpinnerGroup.destroy();
|
||||||
value: checkedValues.map((v) => v.id),
|
});
|
||||||
}).finally(() => {
|
|
||||||
loadingSpinnerGroup.destroy();
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
/**
|
/**
|
||||||
* RADIO FIELD.
|
* RADIO FIELD.
|
||||||
*/
|
*/
|
||||||
.with({ type: FieldType.RADIO }, (field) => {
|
.with({ type: FieldType.RADIO }, (field) => {
|
||||||
const { fieldMeta } = foundField;
|
const selectedRadioIndex = Number(target.getAttr('internalRadioIndex'));
|
||||||
|
const fieldCustomText = Number(field.customText);
|
||||||
|
|
||||||
const checkedValue = target.getAttr('internalRadioValue');
|
if (Number.isNaN(selectedRadioIndex)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
|
|
||||||
// Uncheck the value if it's already pressed.
|
// Uncheck the value if it's already pressed.
|
||||||
const value = field.inserted && checkedValue === field.customText ? null : checkedValue;
|
const value =
|
||||||
|
field.inserted && selectedRadioIndex === fieldCustomText ? null : selectedRadioIndex;
|
||||||
|
|
||||||
void signField(field.id, {
|
void signField(field.id, {
|
||||||
type: FieldType.RADIO,
|
type: FieldType.RADIO,
|
||||||
@ -222,6 +217,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
handleNumberFieldClick({ field, number: null })
|
handleNumberFieldClick({ field, number: null })
|
||||||
.then(async (payload) => {
|
.then(async (payload) => {
|
||||||
if (payload) {
|
if (payload) {
|
||||||
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
await signField(field.id, payload);
|
await signField(field.id, payload);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -236,6 +232,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
handleTextFieldClick({ field, text: null })
|
handleTextFieldClick({ field, text: null })
|
||||||
.then(async (payload) => {
|
.then(async (payload) => {
|
||||||
if (payload) {
|
if (payload) {
|
||||||
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
await signField(field.id, payload);
|
await signField(field.id, payload);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -247,10 +244,11 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
* EMAIL FIELD.
|
* EMAIL FIELD.
|
||||||
*/
|
*/
|
||||||
.with({ type: FieldType.EMAIL }, (field) => {
|
.with({ type: FieldType.EMAIL }, (field) => {
|
||||||
handleEmailFieldClick({ field, email })
|
handleEmailFieldClick({ field, email: localEmail, placeholderEmail })
|
||||||
.then(async (payload) => {
|
.then(async (payload) => {
|
||||||
if (payload) {
|
if (payload) {
|
||||||
await signField(field.id, payload); // Todo: Envelopes - Handle errors
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
|
await signField(field.id, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload?.value) {
|
if (payload?.value) {
|
||||||
@ -265,11 +263,12 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
* INITIALS FIELD.
|
* INITIALS FIELD.
|
||||||
*/
|
*/
|
||||||
.with({ type: FieldType.INITIALS }, (field) => {
|
.with({ type: FieldType.INITIALS }, (field) => {
|
||||||
const initials = fullName ? extractInitials(fullName) : null;
|
const initials = localFullName ? extractInitials(localFullName) : null;
|
||||||
|
|
||||||
handleInitialsFieldClick({ field, initials })
|
handleInitialsFieldClick({ field, initials })
|
||||||
.then(async (payload) => {
|
.then(async (payload) => {
|
||||||
if (payload) {
|
if (payload) {
|
||||||
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
await signField(field.id, payload);
|
await signField(field.id, payload);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -281,9 +280,10 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
* NAME FIELD.
|
* NAME FIELD.
|
||||||
*/
|
*/
|
||||||
.with({ type: FieldType.NAME }, (field) => {
|
.with({ type: FieldType.NAME }, (field) => {
|
||||||
handleNameFieldClick({ field, name: fullName })
|
handleNameFieldClick({ field, name: localFullName })
|
||||||
.then(async (payload) => {
|
.then(async (payload) => {
|
||||||
if (payload) {
|
if (payload) {
|
||||||
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
await signField(field.id, payload);
|
await signField(field.id, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -302,6 +302,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
handleDropdownFieldClick({ field, text: null })
|
handleDropdownFieldClick({ field, text: null })
|
||||||
.then(async (payload) => {
|
.then(async (payload) => {
|
||||||
if (payload) {
|
if (payload) {
|
||||||
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
await signField(field.id, payload);
|
await signField(field.id, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,6 +316,8 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
* DATE FIELD.
|
* DATE FIELD.
|
||||||
*/
|
*/
|
||||||
.with({ type: FieldType.DATE }, (field) => {
|
.with({ type: FieldType.DATE }, (field) => {
|
||||||
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
|
|
||||||
void signField(field.id, {
|
void signField(field.id, {
|
||||||
type: FieldType.DATE,
|
type: FieldType.DATE,
|
||||||
value: !field.inserted,
|
value: !field.inserted,
|
||||||
@ -326,7 +329,6 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
* SIGNATURE FIELD.
|
* SIGNATURE FIELD.
|
||||||
*/
|
*/
|
||||||
.with({ type: FieldType.SIGNATURE }, (field) => {
|
.with({ type: FieldType.SIGNATURE }, (field) => {
|
||||||
// Todo: Envelopes - Reauth
|
|
||||||
handleSignatureFieldClick({
|
handleSignatureFieldClick({
|
||||||
field,
|
field,
|
||||||
signature,
|
signature,
|
||||||
@ -336,11 +338,22 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
})
|
})
|
||||||
.then(async (payload) => {
|
.then(async (payload) => {
|
||||||
if (payload) {
|
if (payload) {
|
||||||
await signField(field.id, payload);
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
}
|
|
||||||
|
|
||||||
if (payload?.value) {
|
if (payload.value) {
|
||||||
setSignature(payload.value);
|
void executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: async (authOptions) => {
|
||||||
|
await signField(field.id, payload, authOptions);
|
||||||
|
|
||||||
|
loadingSpinnerGroup.destroy();
|
||||||
|
},
|
||||||
|
actionTarget: field.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSignature(payload.value);
|
||||||
|
} else {
|
||||||
|
await signField(field.id, payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@ -348,38 +361,63 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
|
|
||||||
console.log('Field clicked');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fieldGroup.off('click');
|
fieldGroup.off('pointerdown');
|
||||||
fieldGroup.on('click', handleFieldGroupClick);
|
fieldGroup.on('pointerdown', handleFieldGroupClick);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||||
|
try {
|
||||||
|
unsafeRenderFieldOnLayer(unparsedField);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setRenderError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signField = async (
|
||||||
|
fieldId: number,
|
||||||
|
payload: TSignEnvelopeFieldValue,
|
||||||
|
authOptions?: TRecipientActionAuth,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { inserted } = await signFieldInternal(fieldId, payload, authOptions);
|
||||||
|
|
||||||
|
// ?: The two callbacks below are used within the embedding context
|
||||||
|
if (inserted && onFieldSigned) {
|
||||||
|
const value = payload.value ? JSON.stringify(payload.value) : undefined;
|
||||||
|
const isBase64 = value ? isBase64Image(value) : undefined;
|
||||||
|
|
||||||
|
onFieldSigned({ fieldId, value, isBase64 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inserted && onFieldUnsigned) {
|
||||||
|
onFieldUnsigned({ fieldId });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Error`,
|
||||||
|
description: t`An error occurred while signing the field.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
* Initialize the Konva page canvas and all fields and interactions.
|
||||||
*/
|
*/
|
||||||
const createPageCanvas = (container: HTMLDivElement) => {
|
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||||
stage.current = new Konva.Stage({
|
|
||||||
container,
|
|
||||||
width: viewport.width,
|
|
||||||
height: viewport.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the main layer for interactive elements.
|
|
||||||
pageLayer.current = new Konva.Layer();
|
|
||||||
stage.current?.add(pageLayer.current);
|
|
||||||
|
|
||||||
console.log({
|
|
||||||
localPageFields,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render the fields.
|
// Render the fields.
|
||||||
for (const field of localPageFields) {
|
for (const field of localPageFields) {
|
||||||
renderFieldOnLayer(field);
|
renderFieldOnLayer(field);
|
||||||
}
|
}
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
currentPageLayer.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -391,26 +429,61 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
localPageFields.forEach((field) => {
|
localPageFields.forEach((field) => {
|
||||||
console.log('Field changed/inserted, rendering on canvas');
|
|
||||||
renderFieldOnLayer(field);
|
renderFieldOnLayer(field);
|
||||||
});
|
});
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
|
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rerender the whole page if the selected assistant recipient changes.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pageLayer.current || !stage.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rerender the whole page.
|
||||||
|
pageLayer.current.destroyChildren();
|
||||||
|
|
||||||
|
localPageFields.forEach((field) => {
|
||||||
|
renderFieldOnLayer(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
pageLayer.current.batchDraw();
|
||||||
|
}, [selectedAssistantRecipient]);
|
||||||
|
|
||||||
if (!currentEnvelopeItem) {
|
if (!currentEnvelopeItem) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
<div
|
||||||
|
className="relative w-full"
|
||||||
|
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||||
|
>
|
||||||
|
{showPendingFieldTooltip &&
|
||||||
|
recipientFieldsRemaining.length > 0 &&
|
||||||
|
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
|
||||||
|
recipientFieldsRemaining[0]?.page === pageContext.pageNumber && (
|
||||||
|
<EnvelopeFieldToolTip
|
||||||
|
key={recipientFieldsRemaining[0].id}
|
||||||
|
field={recipientFieldsRemaining[0]}
|
||||||
|
color="warning"
|
||||||
|
>
|
||||||
|
<Trans>Click to insert field</Trans>
|
||||||
|
</EnvelopeFieldToolTip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The element Konva will inject it's canvas into. */}
|
||||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||||
|
|
||||||
|
{/* Canvas the PDF will be rendered on. */}
|
||||||
<canvas
|
<canvas
|
||||||
className={`${_className}__canvas z-0`}
|
className={`${_className}__canvas z-0`}
|
||||||
height={viewport.height}
|
|
||||||
ref={canvasElement}
|
ref={canvasElement}
|
||||||
width={viewport.width}
|
height={scaledViewport.height}
|
||||||
|
width={scaledViewport.width}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,235 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
import { useNavigate, useRevalidator, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
|
|
||||||
|
import { DocumentSigningCompleteDialog } from '../document-signing/document-signing-complete-dialog';
|
||||||
|
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||||
|
|
||||||
|
export const EnvelopeSignerCompleteDialog = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isDirectTemplate,
|
||||||
|
envelope,
|
||||||
|
setShowPendingFieldTooltip,
|
||||||
|
recipientFieldsRemaining,
|
||||||
|
recipient,
|
||||||
|
nextRecipient,
|
||||||
|
email,
|
||||||
|
fullName,
|
||||||
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
|
const { mutateAsync: completeDocument, isPending } =
|
||||||
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: createDocumentFromDirectTemplate } =
|
||||||
|
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
||||||
|
|
||||||
|
const handleOnNextFieldClick = () => {
|
||||||
|
const nextField = recipientFieldsRemaining[0];
|
||||||
|
|
||||||
|
if (!nextField) {
|
||||||
|
setShowPendingFieldTooltip(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextField.envelopeItemId !== currentEnvelopeItem?.id) {
|
||||||
|
setCurrentEnvelopeItem(nextField.envelopeItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldTooltip = document.querySelector(`#field-tooltip`);
|
||||||
|
|
||||||
|
if (fieldTooltip) {
|
||||||
|
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowPendingFieldTooltip(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnCompleteClick = async (
|
||||||
|
nextSigner?: { name: string; email: string },
|
||||||
|
accessAuthOptions?: TRecipientAccessAuth,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
token: recipient.token,
|
||||||
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
authOptions: accessAuthOptions,
|
||||||
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await completeDocument(payload);
|
||||||
|
|
||||||
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
|
signerId: recipient.id,
|
||||||
|
documentId: envelope.id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onDocumentCompleted) {
|
||||||
|
onDocumentCompleted({
|
||||||
|
token: recipient.token,
|
||||||
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await revalidate();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.documentMeta.redirectUrl) {
|
||||||
|
window.location.href = envelope.documentMeta.redirectUrl;
|
||||||
|
} else {
|
||||||
|
await navigate(`/sign/${recipient.token}/complete`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`We were unable to submit this document at this time. Please try again later.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
onDocumentError?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct template completion flow.
|
||||||
|
*/
|
||||||
|
const handleDirectTemplateCompleteClick = async (
|
||||||
|
nextSigner?: { name: string; email: string },
|
||||||
|
accessAuthOptions?: TRecipientAccessAuth,
|
||||||
|
recipientDetails?: { name: string; email: string },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
|
||||||
|
|
||||||
|
if (directTemplateExternalId) {
|
||||||
|
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recipient.directToken) {
|
||||||
|
throw new Error('Recipient direct token is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await createDocumentFromDirectTemplate({
|
||||||
|
directTemplateToken: recipient.directToken, // The direct template token is inserted into the recipient token for ease of use.
|
||||||
|
directTemplateExternalId,
|
||||||
|
directRecipientName: recipientDetails?.name || fullName,
|
||||||
|
directRecipientEmail: recipientDetails?.email || email,
|
||||||
|
templateUpdatedAt: envelope.updatedAt,
|
||||||
|
signedFieldValues: recipient.fields.map((field) => {
|
||||||
|
let value = field.customText;
|
||||||
|
let isBase64 = false;
|
||||||
|
|
||||||
|
if (field.type === FieldType.SIGNATURE && field.signature) {
|
||||||
|
value = field.signature.signatureImageAsBase64 || field.signature.typedSignature || '';
|
||||||
|
isBase64 = isBase64Image(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: '',
|
||||||
|
fieldId: field.id,
|
||||||
|
value,
|
||||||
|
isBase64,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
nextSigner,
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectUrl = envelope.documentMeta.redirectUrl;
|
||||||
|
|
||||||
|
if (onDocumentCompleted) {
|
||||||
|
await navigate({
|
||||||
|
pathname: `/embed/sign/${token}`,
|
||||||
|
search: window.location.search,
|
||||||
|
hash: window.location.hash,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redirectUrl) {
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
} else {
|
||||||
|
await navigate(`/sign/${token}/complete`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('err', err);
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`We were unable to submit this document at this time. Please try again later.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
onDocumentError?.();
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const directTemplatePayload = useMemo(() => {
|
||||||
|
if (!isDirectTemplate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: fullName,
|
||||||
|
email: email,
|
||||||
|
};
|
||||||
|
}, [email, fullName, isDirectTemplate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentSigningCompleteDialog
|
||||||
|
isSubmitting={isPending}
|
||||||
|
directTemplatePayload={directTemplatePayload}
|
||||||
|
onSignatureComplete={
|
||||||
|
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
|
||||||
|
}
|
||||||
|
documentTitle={envelope.title}
|
||||||
|
fields={recipientFieldsRemaining}
|
||||||
|
fieldsValidated={handleOnNextFieldClick}
|
||||||
|
recipient={recipient}
|
||||||
|
allowDictateNextSigner={Boolean(
|
||||||
|
nextRecipient && envelope.documentMeta.allowDictateNextSigner,
|
||||||
|
)}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
|
||||||
|
}
|
||||||
|
buttonSize="sm"
|
||||||
|
position="center"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -5,6 +5,8 @@ import { FolderType } from '@prisma/client';
|
|||||||
import { FolderIcon, HomeIcon } from 'lucide-react';
|
import { FolderIcon, HomeIcon } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { IS_ENVELOPES_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
@ -19,6 +21,8 @@ import { DocumentUploadButton } from '~/components/general/document/document-upl
|
|||||||
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
import { EnvelopeUploadButton } from '../document/envelope-upload-button';
|
||||||
|
|
||||||
export type FolderGridProps = {
|
export type FolderGridProps = {
|
||||||
type: FolderType;
|
type: FolderType;
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
@ -26,6 +30,7 @@ export type FolderGridProps = {
|
|||||||
|
|
||||||
export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
||||||
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
||||||
@ -94,8 +99,9 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||||
{/* Todo: Envelopes - Feature flag */}
|
{(IS_ENVELOPES_ENABLED || organisation.organisationClaim.flags.allowEnvelopes) && (
|
||||||
{/* <EnvelopeUploadButton type={type} folderId={parentId || undefined} /> */}
|
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
|
||||||
|
)}
|
||||||
|
|
||||||
{type === FolderType.DOCUMENT ? (
|
{type === FolderType.DOCUMENT ? (
|
||||||
<DocumentUploadButton />
|
<DocumentUploadButton />
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { Download } from 'lucide-react';
|
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import type { DocumentData } from '@documenso/prisma/client';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type ShareDocumentDownloadButtonProps = {
|
|
||||||
title: string;
|
|
||||||
documentData: DocumentData;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Todo: Envelopes - Support multiple item downloads.
|
|
||||||
export const ShareDocumentDownloadButton = ({
|
|
||||||
title,
|
|
||||||
documentData,
|
|
||||||
}: ShareDocumentDownloadButtonProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
setIsDownloading(true);
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsDownloading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button loading={isDownloading} onClick={onDownloadClick}>
|
|
||||||
{!isDownloading && <Download className="mr-2 h-4 w-4" />}
|
|
||||||
<Trans>Download</Trans>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -10,9 +10,9 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@ -40,13 +40,17 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const documentData = await putPdfFile(file);
|
const payload = {
|
||||||
|
|
||||||
const { legacyTemplateId: id } = await createTemplate({
|
|
||||||
title: file.name,
|
title: file.name,
|
||||||
templateDocumentDataId: documentData.id,
|
|
||||||
folderId: folderId ?? undefined,
|
folderId: folderId ?? undefined,
|
||||||
});
|
} satisfies TCreateTemplatePayloadSchema;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const { envelopeId: id } = await createTemplate(formData);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Template uploaded`),
|
title: _(msg`Template uploaded`),
|
||||||
|
|||||||
@ -313,8 +313,10 @@ export const TemplateEditForm = ({
|
|||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
key={templateDocumentData.id}
|
key={template.envelopeItems[0].id}
|
||||||
documentData={templateDocumentData}
|
envelopeItem={template.envelopeItems[0]}
|
||||||
|
token={undefined}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -1,30 +1,25 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
|
||||||
|
|
||||||
export type DocumentsTableActionButtonProps = {
|
export type DocumentsTableActionButtonProps = {
|
||||||
row: TDocumentRow;
|
row: TDocumentRow;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
|
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const { toast } = useToast();
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
@ -42,39 +37,6 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
const document = !recipient
|
|
||||||
? await trpcClient.document.get.query(
|
|
||||||
{
|
|
||||||
documentId: row.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
context: {
|
|
||||||
teamId: team?.id?.toString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: await trpcClient.document.getDocumentByToken.query({
|
|
||||||
token: recipient.token,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentData = document?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
throw Error('No document available');
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: row.title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
||||||
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
||||||
return null;
|
return null;
|
||||||
@ -88,6 +50,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
isComplete,
|
isComplete,
|
||||||
isSigned,
|
isSigned,
|
||||||
isCurrentTeamDocument,
|
isCurrentTeamDocument,
|
||||||
|
internalVersion: row.internalVersion,
|
||||||
})
|
})
|
||||||
.with(
|
.with(
|
||||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||||
@ -132,10 +95,17 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<Button className="w-32" onClick={onDownloadClick}>
|
<EnvelopeDownloadDialog
|
||||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
envelopeId={row.envelopeId}
|
||||||
<Trans>Download</Trans>
|
envelopeStatus={row.status}
|
||||||
</Button>
|
token={recipient?.token}
|
||||||
|
trigger={
|
||||||
|
<Button className="w-32">
|
||||||
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
.otherwise(() => <div></div>);
|
.otherwise(() => <div></div>);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
FileDown,
|
|
||||||
FolderInput,
|
FolderInput,
|
||||||
Loader,
|
Loader,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
@ -20,12 +19,10 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -34,7 +31,6 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
||||||
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
||||||
@ -42,6 +38,8 @@ import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialo
|
|||||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
|
||||||
|
|
||||||
export type DocumentsTableActionDropdownProps = {
|
export type DocumentsTableActionDropdownProps = {
|
||||||
row: TDocumentRow;
|
row: TDocumentRow;
|
||||||
onMoveDocument?: () => void;
|
onMoveDocument?: () => void;
|
||||||
@ -54,7 +52,6 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@ -74,58 +71,6 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
const document = !recipient
|
|
||||||
? await trpcClient.document.get.query({
|
|
||||||
documentId: row.id,
|
|
||||||
})
|
|
||||||
: await trpcClient.document.getDocumentByToken.query({
|
|
||||||
token: recipient.token,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentData = document?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: row.title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDownloadOriginalClick = async () => {
|
|
||||||
try {
|
|
||||||
const document = !recipient
|
|
||||||
? await trpcClient.document.get.query({
|
|
||||||
documentId: row.id,
|
|
||||||
})
|
|
||||||
: await trpcClient.document.getDocumentByToken.query({
|
|
||||||
token: recipient.token,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentData = document?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: row.title, version: 'original' });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -176,15 +121,19 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
|
<EnvelopeDownloadDialog
|
||||||
<Download className="mr-2 h-4 w-4" />
|
envelopeId={row.envelopeId}
|
||||||
<Trans>Download</Trans>
|
envelopeStatus={row.status}
|
||||||
</DropdownMenuItem>
|
token={recipient?.token}
|
||||||
|
trigger={
|
||||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||||
<FileDown className="mr-2 h-4 w-4" />
|
<div>
|
||||||
<Trans>Download Original</Trans>
|
<Download className="mr-2 h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
<Trans>Download</Trans>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
@ -253,7 +202,8 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DocumentDuplicateDialog
|
<DocumentDuplicateDialog
|
||||||
id={row.id}
|
id={row.envelopeId}
|
||||||
|
token={recipient?.token}
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -10,11 +10,9 @@ import { DateTime } from 'luxon';
|
|||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
|
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -28,6 +26,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
|
||||||
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
||||||
|
|
||||||
export type DocumentsTableProps = {
|
export type DocumentsTableProps = {
|
||||||
@ -199,28 +198,6 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
const document = await trpcClient.document.getDocumentByToken.query({
|
|
||||||
token: recipient.token,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentData = document?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
throw Error('No document available');
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: row.title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
||||||
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
||||||
return null;
|
return null;
|
||||||
@ -230,6 +207,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
|||||||
isPending,
|
isPending,
|
||||||
isComplete,
|
isComplete,
|
||||||
isSigned,
|
isSigned,
|
||||||
|
internalVersion: row.internalVersion,
|
||||||
})
|
})
|
||||||
.with({ isPending: true, isSigned: false }, () => (
|
.with({ isPending: true, isSigned: false }, () => (
|
||||||
<Button className="w-32" asChild>
|
<Button className="w-32" asChild>
|
||||||
@ -263,10 +241,17 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
|||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<Button className="w-32" onClick={onDownloadClick}>
|
<EnvelopeDownloadDialog
|
||||||
<DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
|
envelopeId={row.envelopeId}
|
||||||
<Trans>Download</Trans>
|
envelopeStatus={row.status}
|
||||||
</Button>
|
token={recipient?.token}
|
||||||
|
trigger={
|
||||||
|
<Button className="w-32">
|
||||||
|
<DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
.otherwise(() => <div></div>);
|
.otherwise(() => <div></div>);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -159,6 +159,7 @@ export const TemplatesTable = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<TemplateUseDialog
|
<TemplateUseDialog
|
||||||
|
envelopeId={row.original.envelopeId}
|
||||||
templateId={row.original.id}
|
templateId={row.original.id}
|
||||||
templateSigningOrder={row.original.templateMeta?.signingOrder}
|
templateSigningOrder={row.original.templateMeta?.signingOrder}
|
||||||
documentDistributionMethod={row.original.templateMeta?.distributionMethod}
|
documentDistributionMethod={row.original.templateMeta?.distributionMethod}
|
||||||
|
|||||||
@ -116,7 +116,7 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
|
|||||||
|
|
||||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||||
|
|
||||||
{banner && <AppBanner banner={banner} />}
|
{banner && !hideHeader && <AppBanner banner={banner} />}
|
||||||
|
|
||||||
{!hideHeader && <Header />}
|
{!hideHeader && <Header />}
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
|
||||||
@ -71,23 +72,6 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: promoteToOwner, isPending: isPromotingToOwner } =
|
|
||||||
trpc.admin.organisationMember.promoteToOwner.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Success`,
|
|
||||||
description: t`Member promoted to owner successfully`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Error`,
|
|
||||||
description: t`We couldn't promote the member to owner. Please try again.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamsColumns = useMemo(() => {
|
const teamsColumns = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -120,23 +104,24 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t`Actions`,
|
header: t`Actions`,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<div className="flex justify-end space-x-2">
|
const isOwner = row.original.userId === organisation?.ownerUserId;
|
||||||
<Button
|
|
||||||
variant="outline"
|
return (
|
||||||
disabled={row.original.userId === organisation?.ownerUserId}
|
<div className="flex justify-end space-x-2">
|
||||||
loading={isPromotingToOwner}
|
<AdminOrganisationMemberUpdateDialog
|
||||||
onClick={async () =>
|
trigger={
|
||||||
promoteToOwner({
|
<Button variant="outline">
|
||||||
organisationId,
|
<Trans>Update role</Trans>
|
||||||
userId: row.original.userId,
|
</Button>
|
||||||
})
|
}
|
||||||
}
|
organisationId={organisationId}
|
||||||
>
|
organisationMember={row.original}
|
||||||
<Trans>Promote to owner</Trans>
|
isOwner={isOwner}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
|
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
|
||||||
}, [organisation]);
|
}, [organisation]);
|
||||||
@ -404,6 +389,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
|
|||||||
claims: {
|
claims: {
|
||||||
teamCount: organisation.organisationClaim.teamCount,
|
teamCount: organisation.organisationClaim.teamCount,
|
||||||
memberCount: organisation.organisationClaim.memberCount,
|
memberCount: organisation.organisationClaim.memberCount,
|
||||||
|
envelopeItemCount: organisation.organisationClaim.envelopeItemCount,
|
||||||
flags: organisation.organisationClaim.flags,
|
flags: organisation.organisationClaim.flags,
|
||||||
},
|
},
|
||||||
originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '',
|
originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '',
|
||||||
@ -561,6 +547,30 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="claims.envelopeItemCount"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Envelope Item Count</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Feature Flags</Trans>
|
<Trans>Feature Flags</Trans>
|
||||||
|
|||||||
@ -5,7 +5,10 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
import { SubscriptionStatus } from '@prisma/client';
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
import { Link, Outlet } from 'react-router';
|
import { Link, Outlet } from 'react-router';
|
||||||
|
|
||||||
import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
|
import {
|
||||||
|
DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||||
|
PAID_PLAN_LIMITS,
|
||||||
|
} from '@documenso/ee/server-only/limits/constants';
|
||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
@ -38,12 +41,14 @@ export default function Layout() {
|
|||||||
recipients: 0,
|
recipients: 0,
|
||||||
directTemplates: 0,
|
directTemplates: 0,
|
||||||
},
|
},
|
||||||
|
maximumEnvelopeItemCount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quota: PAID_PLAN_LIMITS,
|
quota: PAID_PLAN_LIMITS,
|
||||||
remaining: PAID_PLAN_LIMITS,
|
remaining: PAID_PLAN_LIMITS,
|
||||||
|
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||||
};
|
};
|
||||||
}, [organisation?.subscription]);
|
}, [organisation?.subscription]);
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
mapFieldsWithRecipients,
|
mapFieldsWithRecipients,
|
||||||
} from '@documenso/ui/components/document/document-read-only-fields';
|
} from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@ -87,6 +88,8 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
|
|
||||||
const documentRootPath = formatDocumentsPath(team.url);
|
const documentRootPath = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
|
const isMultiEnvelopeItem = envelope.envelopeItems.length > 1 && envelope.internalVersion === 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
{envelope.status === DocumentStatus.PENDING && (
|
{envelope.status === DocumentStatus.PENDING && (
|
||||||
@ -140,40 +143,61 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||||
<Card
|
{envelope.internalVersion === 2 ? (
|
||||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||||
gradient
|
<EnvelopeRenderProvider
|
||||||
>
|
envelope={envelope}
|
||||||
<CardContent className="p-2">
|
token={undefined}
|
||||||
{envelope.internalVersion === 2 ? (
|
fields={envelope.fields}
|
||||||
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
|
recipients={envelope.recipients}
|
||||||
|
overrideSettings={{
|
||||||
|
showRecipientSigningStatus: true,
|
||||||
|
showRecipientTooltip: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMultiEnvelopeItem && (
|
||||||
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||||
</EnvelopeRenderProvider>
|
<CardContent className="p-2">
|
||||||
) : (
|
<PDFViewerKonvaLazy
|
||||||
<>
|
renderer="preview"
|
||||||
{envelope.status !== DocumentStatus.COMPLETED && (
|
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||||
<DocumentReadOnlyFields
|
|
||||||
fields={mapFieldsWithRecipients(envelope.fields, envelope.recipients)}
|
|
||||||
documentMeta={envelope.documentMeta || undefined}
|
|
||||||
showRecipientTooltip={true}
|
|
||||||
showRecipientColors={true}
|
|
||||||
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<PDFViewer
|
</EnvelopeRenderProvider>
|
||||||
document={envelope}
|
</div>
|
||||||
key={envelope.envelopeItems[0].id}
|
) : (
|
||||||
documentData={envelope.envelopeItems[0].documentData}
|
<Card
|
||||||
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
|
gradient
|
||||||
|
>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
{envelope.status !== DocumentStatus.COMPLETED && (
|
||||||
|
<DocumentReadOnlyFields
|
||||||
|
fields={mapFieldsWithRecipients(envelope.fields, envelope.recipients)}
|
||||||
|
documentMeta={envelope.documentMeta || undefined}
|
||||||
|
showRecipientTooltip={true}
|
||||||
|
showRecipientColors={true}
|
||||||
|
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||||
/>
|
/>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<PDFViewer
|
||||||
|
envelopeItem={envelope.envelopeItems[0]}
|
||||||
|
token={undefined}
|
||||||
|
key={envelope.envelopeItems[0].id}
|
||||||
|
version="signed"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn('col-span-12 lg:col-span-6 xl:col-span-5', isMultiEnvelopeItem && 'mt-20')}
|
||||||
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
<div className="flex flex-row items-center justify-between px-4">
|
<div className="flex flex-row items-center justify-between px-4">
|
||||||
|
|||||||
@ -99,7 +99,12 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
||||||
<EnvelopeRenderProvider envelope={envelope}>
|
<EnvelopeRenderProvider
|
||||||
|
envelope={envelope}
|
||||||
|
token={undefined}
|
||||||
|
fields={envelope.fields}
|
||||||
|
recipients={envelope.recipients}
|
||||||
|
>
|
||||||
<EnvelopeEditor />
|
<EnvelopeEditor />
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
</EnvelopeEditorProvider>
|
</EnvelopeEditorProvider>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|||||||
import { logDocumentAccess } from '@documenso/lib/utils/logger';
|
import { logDocumentAccess } from '@documenso/lib/utils/logger';
|
||||||
import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
|
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
|
||||||
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
||||||
@ -122,11 +123,13 @@ export default function DocumentEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{document.useLegacyFieldInsertion && (
|
<div className="flex items-center gap-x-4">
|
||||||
<div>
|
<DocumentAttachmentsPopover envelopeId={document.envelopeId} />
|
||||||
|
|
||||||
|
{document.useLegacyFieldInsertion && (
|
||||||
<LegacyFieldWarningPopover type="document" documentId={document.id} />
|
<LegacyFieldWarningPopover type="document" documentId={document.id} />
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentEditForm
|
<DocumentEditForm
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/t
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
@ -108,6 +109,8 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const isMultiEnvelopeItem = envelope.envelopeItems.length > 1 && envelope.internalVersion === 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<Link to={templateRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
<Link to={templateRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
@ -163,39 +166,59 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||||
<Card
|
{envelope.internalVersion === 2 ? (
|
||||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||||
gradient
|
<EnvelopeRenderProvider
|
||||||
>
|
envelope={envelope}
|
||||||
<CardContent className="p-2">
|
token={undefined}
|
||||||
{envelope.internalVersion === 2 ? (
|
fields={envelope.fields}
|
||||||
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
|
recipients={envelope.recipients}
|
||||||
|
overrideSettings={{
|
||||||
|
showRecipientTooltip: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMultiEnvelopeItem && (
|
||||||
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||||
</EnvelopeRenderProvider>
|
<CardContent className="p-2">
|
||||||
) : (
|
<PDFViewerKonvaLazy
|
||||||
<>
|
renderer="preview"
|
||||||
<DocumentReadOnlyFields
|
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||||
fields={readOnlyFields}
|
/>
|
||||||
showFieldStatus={false}
|
</CardContent>
|
||||||
showRecipientTooltip={true}
|
</Card>
|
||||||
showRecipientColors={true}
|
</EnvelopeRenderProvider>
|
||||||
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
</div>
|
||||||
documentMeta={mockedDocumentMeta}
|
) : (
|
||||||
/>
|
<Card
|
||||||
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
|
gradient
|
||||||
|
>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<DocumentReadOnlyFields
|
||||||
|
fields={readOnlyFields}
|
||||||
|
showFieldStatus={false}
|
||||||
|
showRecipientTooltip={true}
|
||||||
|
showRecipientColors={true}
|
||||||
|
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||||
|
documentMeta={mockedDocumentMeta}
|
||||||
|
/>
|
||||||
|
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
document={envelope}
|
envelopeItem={envelope.envelopeItems[0]}
|
||||||
key={envelope.envelopeItems[0].id}
|
token={undefined}
|
||||||
documentData={envelope.envelopeItems[0].documentData}
|
version="signed"
|
||||||
/>
|
key={envelope.envelopeItems[0].id}
|
||||||
</>
|
/>
|
||||||
)}
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
)}
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div
|
||||||
|
className={cn('col-span-12 lg:col-span-6 xl:col-span-5', isMultiEnvelopeItem && 'mt-20')}
|
||||||
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
<div className="flex flex-row items-center justify-between px-4">
|
<div className="flex flex-row items-center justify-between px-4">
|
||||||
@ -223,6 +246,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
|
|
||||||
<div className="mt-4 border-t px-4 pt-4">
|
<div className="mt-4 border-t px-4 pt-4">
|
||||||
<TemplateUseDialog
|
<TemplateUseDialog
|
||||||
|
envelopeId={envelope.id}
|
||||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||||
templateSigningOrder={envelope.documentMeta?.signingOrder}
|
templateSigningOrder={envelope.documentMeta?.signingOrder}
|
||||||
recipients={envelope.recipients}
|
recipients={envelope.recipients}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { getTemplateById } from '@documenso/lib/server-only/template/get-templat
|
|||||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
||||||
|
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
|
||||||
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
||||||
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
|
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
|
||||||
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
|
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
|
||||||
@ -87,6 +88,8 @@ export default function TemplateEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
|
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
|
||||||
|
<DocumentAttachmentsPopover envelopeId={template.envelopeId} />
|
||||||
|
|
||||||
<TemplateDirectLinkDialog
|
<TemplateDirectLinkDialog
|
||||||
templateId={template.id}
|
templateId={template.id}
|
||||||
directLink={template.directLink}
|
directLink={template.directLink}
|
||||||
|
|||||||
@ -22,7 +22,9 @@ export default function RecipientLayout({ matches }: Route.ComponentProps) {
|
|||||||
|
|
||||||
// Hide the header for signing routes.
|
// Hide the header for signing routes.
|
||||||
const hideHeader = matches.some(
|
const hideHeader = matches.some(
|
||||||
(match) => match?.id === 'routes/_recipient+/sign.$token+/_index',
|
(match) =>
|
||||||
|
match?.id === 'routes/_recipient+/sign.$token+/_index' ||
|
||||||
|
match?.id === 'routes/_recipient+/d.$token+/_index',
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -4,20 +4,28 @@ import { redirect } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
|
||||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
||||||
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
|
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
|
||||||
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
|
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
|
||||||
|
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||||
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||||
|
import { DocumentSigningPageViewV2 } from '~/components/general/document-signing/document-signing-page-view-v2';
|
||||||
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||||
|
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
|
||||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
import type { Route } from './+types/_index';
|
import type { Route } from './+types/_index';
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||||
const session = await getOptionalSession(request);
|
const session = await getOptionalSession(request);
|
||||||
|
|
||||||
const { token } = params;
|
const { token } = params;
|
||||||
@ -55,27 +63,108 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!isAccessAuthValid) {
|
if (!isAccessAuthValid) {
|
||||||
return superLoaderJson({
|
return {
|
||||||
isAccessAuthValid: false as const,
|
isAccessAuthValid: false as const,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return superLoaderJson({
|
return {
|
||||||
isAccessAuthValid: true,
|
isAccessAuthValid: true,
|
||||||
template: {
|
template: {
|
||||||
...template,
|
...template,
|
||||||
folder: null,
|
folder: null,
|
||||||
},
|
},
|
||||||
directTemplateRecipient,
|
directTemplateRecipient,
|
||||||
|
} as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||||
|
const session = await getOptionalSession(request);
|
||||||
|
|
||||||
|
const { token } = params;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getEnvelopeForDirectTemplateSigning({
|
||||||
|
token,
|
||||||
|
userId: session?.user?.id,
|
||||||
|
})
|
||||||
|
.then((envelopeForSigning) => {
|
||||||
|
return {
|
||||||
|
isDocumentAccessValid: true,
|
||||||
|
envelopeForSigning,
|
||||||
|
} as const;
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
const error = AppError.parseError(e);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
return {
|
||||||
|
isDocumentAccessValid: false,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||||
|
const { token } = loaderArgs.params;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const directEnvelope = await prisma.envelope.findFirst({
|
||||||
|
where: {
|
||||||
|
directLink: {
|
||||||
|
enabled: true,
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
internalVersion: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!directEnvelope) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directEnvelope.internalVersion === 2) {
|
||||||
|
const payloadV2 = await handleV2Loader(loaderArgs);
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
version: 2,
|
||||||
|
payload: payloadV2,
|
||||||
|
} as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadV1 = await handleV1Loader(loaderArgs);
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
version: 1,
|
||||||
|
payload: payloadV1,
|
||||||
} as const);
|
} as const);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DirectTemplatePage() {
|
export default function DirectTemplatePage() {
|
||||||
const { sessionData } = useOptionalSession();
|
|
||||||
const user = sessionData?.user;
|
|
||||||
|
|
||||||
const data = useSuperLoaderData<typeof loader>();
|
const data = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
if (data.version === 2) {
|
||||||
|
return <DirectSigningPageV2 data={data.payload} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DirectSigningPageV1 data={data.payload} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loader>> }) => {
|
||||||
|
const { sessionData } = useOptionalSession();
|
||||||
|
|
||||||
|
const user = sessionData?.user;
|
||||||
|
|
||||||
// Should not be possible for directLink to be null.
|
// Should not be possible for directLink to be null.
|
||||||
if (!data.isAccessAuthValid) {
|
if (!data.isAccessAuthValid) {
|
||||||
return <DirectTemplateAuthPageView />;
|
return <DirectTemplateAuthPageView />;
|
||||||
@ -95,30 +184,72 @@ export default function DirectTemplatePage() {
|
|||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
recipient={directTemplateRecipient}
|
recipient={directTemplateRecipient}
|
||||||
|
isDirectTemplate={true}
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<>
|
||||||
<h1
|
{sessionData?.user && <AuthenticatedHeader />}
|
||||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
|
||||||
title={template.title}
|
|
||||||
>
|
|
||||||
{template.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<UsersIcon className="h-4 w-4" />
|
<h1
|
||||||
<p className="text-muted-foreground/80">
|
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
<Plural value={template.recipients.length} one="# recipient" other="# recipients" />
|
title={template.title}
|
||||||
</p>
|
>
|
||||||
|
{template.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
|
||||||
|
<UsersIcon className="h-4 w-4" />
|
||||||
|
<p className="text-muted-foreground/80">
|
||||||
|
<Plural value={template.recipients.length} one="# recipient" other="# recipients" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DirectTemplatePageView
|
||||||
|
directTemplateRecipient={directTemplateRecipient}
|
||||||
|
directTemplateToken={template.directLink.token}
|
||||||
|
template={template}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
<DirectTemplatePageView
|
|
||||||
directTemplateRecipient={directTemplateRecipient}
|
|
||||||
directTemplateToken={template.directLink.token}
|
|
||||||
template={template}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DocumentSigningAuthProvider>
|
</DocumentSigningAuthProvider>
|
||||||
</DocumentSigningProvider>
|
</DocumentSigningProvider>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loader>> }) => {
|
||||||
|
const { sessionData } = useOptionalSession();
|
||||||
|
|
||||||
|
const user = sessionData?.user;
|
||||||
|
|
||||||
|
if (!data.isDocumentAccessValid) {
|
||||||
|
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { envelope, recipient } = data.envelopeForSigning;
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: envelope.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEmailForced = derivedRecipientAccessAuth.includes(DocumentAccessAuth.ACCOUNT);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EnvelopeSigningProvider
|
||||||
|
envelopeData={data.envelopeForSigning}
|
||||||
|
email={isEmailForced ? user?.email || '' : ''} // Doing this allows us to let users change the email if they want to for non-auth templates.
|
||||||
|
fullName={user?.name}
|
||||||
|
signature={user?.signature}
|
||||||
|
>
|
||||||
|
<DocumentSigningAuthProvider
|
||||||
|
documentAuthOptions={envelope.authOptions}
|
||||||
|
recipient={recipient}
|
||||||
|
user={user}
|
||||||
|
>
|
||||||
|
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||||
|
<DocumentSigningPageViewV2 />
|
||||||
|
</EnvelopeRenderProvider>
|
||||||
|
</DocumentSigningAuthProvider>
|
||||||
|
</EnvelopeSigningProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -492,7 +492,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<EnvelopeRenderProvider envelope={envelope}>
|
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||||
<DocumentSigningPageViewV2 />
|
<DocumentSigningPageViewV2 />
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
</DocumentSigningAuthProvider>
|
</DocumentSigningAuthProvider>
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||||
import { CheckCircle2, Clock8, FileSearch } from 'lucide-react';
|
import { CheckCircle2, Clock8, DownloadIcon } from 'lucide-react';
|
||||||
import { Link, useRevalidator } from 'react-router';
|
import { Link, useRevalidator } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -20,14 +19,13 @@ import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-emai
|
|||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
|
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
|
||||||
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
|
||||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
import { ClaimAccount } from '~/components/general/claim-account';
|
import { ClaimAccount } from '~/components/general/claim-account';
|
||||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||||
|
|
||||||
@ -207,24 +205,16 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
|||||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||||
|
|
||||||
{isDocumentCompleted(document.status) ? (
|
{isDocumentCompleted(document.status) && (
|
||||||
<DocumentDownloadButton
|
<EnvelopeDownloadDialog
|
||||||
className="flex-1"
|
envelopeId={document.envelopeId}
|
||||||
fileName={document.title}
|
envelopeStatus={document.status}
|
||||||
documentData={document.documentData}
|
envelopeItems={document.envelopeItems}
|
||||||
disabled={!isDocumentCompleted(document.status)}
|
token={recipient?.token}
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DocumentDialog
|
|
||||||
documentData={document.documentData}
|
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button type="button" variant="outline" className="flex-1">
|
||||||
className="text-[11px]"
|
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||||
title={_(msg`Signatures will appear once the document has been completed`)}
|
<Trans>Download</Trans>
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<FileSearch className="mr-2 h-5 w-5" strokeWidth={1.7} />
|
|
||||||
<Trans>View Original Document</Trans>
|
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -60,6 +60,7 @@ export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) =>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
document,
|
document,
|
||||||
|
token: slug,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,7 +75,7 @@ export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SharePage() {
|
export default function SharePage() {
|
||||||
const { document } = useLoaderData<typeof loader>();
|
const { document, token } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
if (document) {
|
if (document) {
|
||||||
return (
|
return (
|
||||||
@ -86,6 +87,7 @@ export default function SharePage() {
|
|||||||
envelopeItems={document.envelopeItems}
|
envelopeItems={document.envelopeItems}
|
||||||
recipientCount={document.recipientCount}
|
recipientCount={document.recipientCount}
|
||||||
completedDate={document.completedAt ?? undefined}
|
completedDate={document.completedAt ?? undefined}
|
||||||
|
token={token}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user