mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
Compare commits
12 Commits
main
...
a08a77e98b
| Author | SHA1 | Date | |
|---|---|---|---|
| a08a77e98b | |||
| 13d9ca7a0e | |||
| d25565b7d0 | |||
| 91421a7d62 | |||
| a9f1e39b10 | |||
| b37748654e | |||
| b3ed80d721 | |||
| b3cb750470 | |||
| 1e52493144 | |||
| ab95e80987 | |||
| 1780a5c262 | |||
| cb9bf407f7 |
@ -19,15 +19,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
type DocumentDuplicateDialogProps = {
|
type DocumentDuplicateDialogProps = {
|
||||||
id: string;
|
id: number;
|
||||||
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) => {
|
||||||
@ -38,23 +36,27 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
const { data: document, isLoading } = trpcReact.document.get.useQuery(
|
||||||
trpcReact.envelope.item.getManyByToken.useQuery(
|
|
||||||
{
|
{
|
||||||
envelopeId: id,
|
documentId: id,
|
||||||
access: token ? { type: 'recipient', token } : { type: 'user' },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: open,
|
queryHash: `document-duplicate-dialog-${id}`,
|
||||||
|
enabled: open === true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const envelopeItems = envelopeItemsPayload?.data || [];
|
const documentData = document?.documentData
|
||||||
|
? {
|
||||||
|
...document.documentData,
|
||||||
|
data: document.documentData.initialData,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
||||||
trpcReact.envelope.duplicate.useMutation({
|
trpcReact.document.duplicate.useMutation({
|
||||||
onSuccess: async ({ id }) => {
|
onSuccess: async ({ id }) => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document Duplicated`),
|
title: _(msg`Document Duplicated`),
|
||||||
@ -69,7 +71,7 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
const onDuplicate = async () => {
|
const onDuplicate = async () => {
|
||||||
try {
|
try {
|
||||||
await duplicateEnvelope({ envelopeId: id });
|
await duplicateDocument({ documentId: id });
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
@ -81,14 +83,14 @@ export const DocumentDuplicateDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Duplicate</Trans>
|
<Trans>Duplicate</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (
|
{!documentData || isLoading ? (
|
||||||
<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>
|
||||||
@ -96,12 +98,7 @@ 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
|
<PDFViewer key={document?.id} documentData={documentData} />
|
||||||
key={envelopeItems[0].id}
|
|
||||||
envelopeItem={envelopeItems[0]}
|
|
||||||
token={undefined}
|
|
||||||
version="original"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -118,8 +115,8 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isDuplicating}
|
disabled={isDuplicateLoading || isLoading}
|
||||||
loading={isDuplicating}
|
loading={isDuplicateLoading}
|
||||||
onClick={onDuplicate}
|
onClick={onDuplicate}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -336,7 +336,7 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
<Trans>Message</Trans>{' '}
|
<Trans>Message</Trans>{' '}
|
||||||
<span className="text-muted-foreground">(Optional)</span>
|
<span className="text-muted-foreground">(Optional)</span>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="text-muted-foreground p-4">
|
<TooltipContent className="text-muted-foreground p-4">
|
||||||
|
|||||||
@ -2,10 +2,11 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
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 { DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||||
import { DownloadIcon, FileTextIcon } from 'lucide-react';
|
import { DownloadIcon, FileTextIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -19,7 +20,9 @@ import {
|
|||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' | 'order'>;
|
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'title' | 'order'> & {
|
||||||
|
documentData: DocumentData;
|
||||||
|
};
|
||||||
|
|
||||||
type EnvelopeDownloadDialogProps = {
|
type EnvelopeDownloadDialogProps = {
|
||||||
envelopeId: string;
|
envelopeId: string;
|
||||||
@ -61,12 +64,12 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
access: token ? { type: 'recipient', token } : { type: 'user' },
|
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
initialData: initialEnvelopeItems ? { data: initialEnvelopeItems } : undefined,
|
initialData: initialEnvelopeItems ? { envelopeItems: initialEnvelopeItems } : undefined,
|
||||||
enabled: open,
|
enabled: open,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const envelopeItems = envelopeItemsPayload?.data || [];
|
const envelopeItems = envelopeItemsPayload?.envelopeItems || [];
|
||||||
|
|
||||||
const onDownload = async (
|
const onDownload = async (
|
||||||
envelopeItem: EnvelopeItemToDownload,
|
envelopeItem: EnvelopeItemToDownload,
|
||||||
@ -84,11 +87,25 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadPDF({
|
const data = await getFile({
|
||||||
envelopeItem,
|
type: envelopeItem.documentData.type,
|
||||||
token,
|
data:
|
||||||
fileName: envelopeItem.title,
|
version === 'signed'
|
||||||
version,
|
? envelopeItem.documentData.data
|
||||||
|
: envelopeItem.documentData.initialData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob([data], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseTitle = envelopeItem.title.replace(/\.pdf$/, '');
|
||||||
|
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
||||||
|
const filename = `${baseTitle}${suffix}`;
|
||||||
|
|
||||||
|
downloadFile({
|
||||||
|
filename,
|
||||||
|
data: blob,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsDownloadingState((prev) => ({
|
setIsDownloadingState((prev) => ({
|
||||||
@ -129,7 +146,7 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{isLoadingEnvelopeItems ? (
|
{isLoadingEnvelopeItems ? (
|
||||||
<>
|
<>
|
||||||
{Array.from({ length: 1 }).map((_, index) => (
|
{Array.from({ length: 2 }).map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
|
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
|
||||||
@ -158,7 +175,6 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{/* Todo: Envelopes - Fix overflow */}
|
|
||||||
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
|
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
<Trans>PDF Document</Trans>
|
<Trans>PDF Document</Trans>
|
||||||
@ -176,7 +192,7 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
|
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
|
||||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<Trans context="Original document (adjective)">Original</Trans>
|
<Trans>Original</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{envelopeStatus === DocumentStatus.COMPLETED && (
|
{envelopeStatus === DocumentStatus.COMPLETED && (
|
||||||
@ -190,7 +206,7 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
|
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
|
||||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<Trans context="Signed document (adjective)">Signed</Trans>
|
<Trans>Signed</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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 ({ id }) => {
|
onSuccess: async ({ duplicatedEnvelopeId }) => {
|
||||||
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}/${id}/edit`);
|
await navigate(`${path}/${duplicatedEnvelopeId}/edit`);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -185,10 +185,6 @@ 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';
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { startRegistration } from '@simplewebauthn/browser';
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
import { KeyRoundIcon } from 'lucide-react';
|
import { KeyRoundIcon } from 'lucide-react';
|
||||||
@ -209,11 +209,7 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
|
|||||||
))
|
))
|
||||||
.with('TOO_MANY_PASSKEYS', () => (
|
.with('TOO_MANY_PASSKEYS', () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<Plural
|
<Trans>You cannot have more than {MAXIMUM_PASSKEYS} passkeys.</Trans>
|
||||||
value={MAXIMUM_PASSKEYS}
|
|
||||||
one="You cannot have more than # passkey."
|
|
||||||
other="You cannot have more than # passkeys."
|
|
||||||
/>
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
.with('InvalidStateError', () => (
|
.with('InvalidStateError', () => (
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
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 { createCallable } from 'react-call';
|
import { createCallable } from 'react-call';
|
||||||
@ -27,71 +28,49 @@ 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';
|
||||||
|
|
||||||
export type SignFieldNumberDialogProps = {
|
|
||||||
fieldMeta: TNumberFieldMeta;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, string | null>(
|
|
||||||
({ call, fieldMeta }) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
// Needs to be inside dialog for translation purposes.
|
|
||||||
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
||||||
|
let schema = z.coerce.number({
|
||||||
|
invalid_type_error: msg`Please enter a valid number`.id,
|
||||||
|
});
|
||||||
|
|
||||||
const { numberFormat, minValue, maxValue } = fieldMeta;
|
const { numberFormat, minValue, maxValue } = fieldMeta;
|
||||||
|
|
||||||
|
if (typeof minValue === 'number') {
|
||||||
|
schema = schema.min(minValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof maxValue === 'number') {
|
||||||
|
schema = schema.max(maxValue);
|
||||||
|
}
|
||||||
|
|
||||||
if (numberFormat) {
|
if (numberFormat) {
|
||||||
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
|
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
|
||||||
|
|
||||||
if (foundRegex) {
|
if (!foundRegex) {
|
||||||
return z.string().refine(
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema.refine(
|
||||||
(value) => {
|
(value) => {
|
||||||
return foundRegex.test(value.toString());
|
return foundRegex.test(value.toString());
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: t`Number needs to be formatted as ${numberFormat}`,
|
message: msg`Number needs to be formatted as ${numberFormat}`.id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Not gong to work with min/max numbers + number format
|
return schema;
|
||||||
// Since currently doesn't work in V1 going to ignore for now.
|
|
||||||
return z.string().superRefine((value, ctx) => {
|
|
||||||
const isValidNumber = /^[0-9,.]+$/.test(value.toString());
|
|
||||||
|
|
||||||
if (!isValidNumber) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: t`Please enter a valid number`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof minValue === 'number' && parseFloat(value) < minValue) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.too_small,
|
|
||||||
minimum: minValue,
|
|
||||||
inclusive: true,
|
|
||||||
type: 'number',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof maxValue === 'number' && parseFloat(value) > maxValue) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.too_big,
|
|
||||||
maximum: maxValue,
|
|
||||||
inclusive: true,
|
|
||||||
type: 'number',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SignFieldNumberDialogProps = {
|
||||||
|
fieldMeta: TNumberFieldMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, number | null>(
|
||||||
|
({ call, fieldMeta }) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
const ZSignFieldNumberFormSchema = z.object({
|
const ZSignFieldNumberFormSchema = z.object({
|
||||||
number: createNumberFieldSchema(fieldMeta),
|
number: createNumberFieldSchema(fieldMeta),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,17 +54,13 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
|
|||||||
setIsUploadingFile(true);
|
setIsUploadingFile(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const response = await putPdfFile(file);
|
||||||
|
|
||||||
|
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`),
|
||||||
@ -96,7 +92,7 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="cursor-pointer" disabled={!user.emailVerified}>
|
<Button className="cursor-pointer" disabled={!user.emailVerified}>
|
||||||
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
||||||
<Trans>Template (Legacy)</Trans>
|
<Trans>New Template</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
|
|||||||
@ -143,7 +143,7 @@ export function TemplateUseDialog({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const envelopeItems = response?.data ?? [];
|
const envelopeItems = response?.envelopeItems ?? [];
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromTemplate } =
|
const { mutateAsync: createDocumentFromTemplate } =
|
||||||
trpc.template.createDocumentFromTemplate.useMutation();
|
trpc.template.createDocumentFromTemplate.useMutation();
|
||||||
|
|||||||
@ -4,7 +4,6 @@ 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';
|
||||||
@ -13,6 +12,7 @@ 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,14 +83,21 @@ export const ConfigureFieldsView = ({
|
|||||||
|
|
||||||
const normalizedDocumentData = useMemo(() => {
|
const normalizedDocumentData = useMemo(() => {
|
||||||
if (documentData) {
|
if (documentData) {
|
||||||
return documentData.data;
|
return documentData;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!configData.documentData) {
|
if (!configData.documentData) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return base64.encode(configData.documentData.data);
|
const data = 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(() => {
|
||||||
@ -534,15 +541,7 @@ export const ConfigureFieldsView = ({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
{normalizedDocumentData && (
|
{normalizedDocumentData && (
|
||||||
<div>
|
<div>
|
||||||
<PDFViewer
|
<PDFViewer documentData={normalizedDocumentData} />
|
||||||
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,7 +9,6 @@ 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;
|
||||||
};
|
};
|
||||||
@ -18,7 +17,6 @@ export const EmbedAuthenticationRequired = ({
|
|||||||
email,
|
email,
|
||||||
returnTo,
|
returnTo,
|
||||||
// isGoogleSSOEnabled,
|
// isGoogleSSOEnabled,
|
||||||
// isMicrosoftSSOEnabled,
|
|
||||||
// isOIDCSSOEnabled,
|
// isOIDCSSOEnabled,
|
||||||
// oidcProviderLabel,
|
// oidcProviderLabel,
|
||||||
}: EmbedAuthenticationRequiredProps) => {
|
}: EmbedAuthenticationRequiredProps) => {
|
||||||
@ -39,7 +37,6 @@ 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, EnvelopeItem, Recipient, Signature } from '@prisma/client';
|
import type { DocumentMeta, Recipient, Signature } from '@prisma/client';
|
||||||
import { type Field, FieldType } from '@prisma/client';
|
import { type DocumentData, 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';
|
||||||
@ -47,7 +47,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
|||||||
token: string;
|
token: string;
|
||||||
envelopeId: string;
|
envelopeId: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
documentData: DocumentData;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
metadata?: DocumentMeta | null;
|
metadata?: DocumentMeta | null;
|
||||||
@ -59,7 +59,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
token,
|
token,
|
||||||
envelopeId,
|
envelopeId,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
envelopeItems,
|
documentData,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
metadata,
|
metadata,
|
||||||
@ -335,9 +335,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
envelopeItem={envelopeItems[0]}
|
documentData={documentData}
|
||||||
token={recipient.token}
|
|
||||||
version="signed"
|
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,232 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -3,8 +3,14 @@ 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, EnvelopeItem } from '@prisma/client';
|
import type { DocumentMeta } from '@prisma/client';
|
||||||
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
import {
|
||||||
|
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';
|
||||||
@ -40,11 +46,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 EmbedSignDocumentV1ClientPageProps = {
|
export type EmbedSignDocumentClientPageProps = {
|
||||||
token: string;
|
token: string;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
envelopeId: string;
|
envelopeId: string;
|
||||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
documentData: DocumentData;
|
||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
completedFields: DocumentField[];
|
completedFields: DocumentField[];
|
||||||
@ -55,11 +61,11 @@ export type EmbedSignDocumentV1ClientPageProps = {
|
|||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedSignDocumentV1ClientPage = ({
|
export const EmbedSignDocumentClientPage = ({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
envelopeId,
|
envelopeId,
|
||||||
envelopeItems,
|
documentData,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
completedFields,
|
completedFields,
|
||||||
@ -68,7 +74,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
allowWhitelabelling = false,
|
allowWhitelabelling = false,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
}: EmbedSignDocumentV1ClientPageProps) => {
|
}: EmbedSignDocumentClientPageProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -287,9 +293,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="embed--DocumentViewer flex-1">
|
<div className="embed--DocumentViewer flex-1">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
envelopeItem={envelopeItems[0]}
|
documentData={documentData}
|
||||||
token={token}
|
|
||||||
version="signed"
|
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -1,101 +0,0 @@
|
|||||||
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,9 +226,7 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
envelopeItem={document.envelopeItems[0]}
|
documentData={document.documentData}
|
||||||
token={token}
|
|
||||||
version="signed"
|
|
||||||
onDocumentLoad={() => {
|
onDocumentLoad={() => {
|
||||||
setHasDocumentLoaded(true);
|
setHasDocumentLoaded(true);
|
||||||
onDocumentReady?.();
|
onDocumentReady?.();
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
import { useTransition } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import type { DateRange } from '@documenso/lib/types/search-params';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
|
|
||||||
type DateRangeFilterProps = {
|
|
||||||
currentRange: DateRange;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const handleRangeChange = (value: string) => {
|
|
||||||
startTransition(() => {
|
|
||||||
updateSearchParams({
|
|
||||||
dateRange: value as DateRange,
|
|
||||||
page: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Select value={currentRange} onValueChange={handleRangeChange} disabled={isPending}>
|
|
||||||
<SelectTrigger className="w-48">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="last30days">{_(msg`Last 30 Days`)}</SelectItem>
|
|
||||||
<SelectItem value="last90days">{_(msg`Last 90 Days`)}</SelectItem>
|
|
||||||
<SelectItem value="lastYear">{_(msg`Last Year`)}</SelectItem>
|
|
||||||
<SelectItem value="allTime">{_(msg`All Time`)}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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 { Trans, useLingui } from '@lingui/react/macro';
|
import { 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 { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
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,8 +29,6 @@ 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'];
|
||||||
|
|
||||||
@ -70,9 +68,6 @@ 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);
|
||||||
|
|
||||||
@ -93,13 +88,14 @@ 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) {
|
||||||
const logoUrl =
|
void getFile(file).then((binaryData) => {
|
||||||
context === 'Team'
|
const objectUrl = URL.createObjectURL(new Blob([binaryData]));
|
||||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
|
|
||||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
|
|
||||||
|
|
||||||
setPreviewUrl(logoUrl);
|
setPreviewUrl(objectUrl);
|
||||||
setHasLoadedPreview(true);
|
setHasLoadedPreview(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import type { z } from 'zod';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_FIELD_FONT_SIZE,
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
type TDateFieldMeta as DateFieldMeta,
|
type TDateFieldMeta as DateFieldMeta,
|
||||||
FIELD_DEFAULT_GENERIC_ALIGN,
|
|
||||||
ZDateFieldMeta,
|
ZDateFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
import { Form } from '@documenso/ui/primitives/form/form';
|
import { Form } from '@documenso/ui/primitives/form/form';
|
||||||
@ -40,7 +39,7 @@ export const EditorFieldDateForm = ({
|
|||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
|
textAlign: value.textAlign || 'left',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import type { z } from 'zod';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_FIELD_FONT_SIZE,
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
type TEmailFieldMeta as EmailFieldMeta,
|
type TEmailFieldMeta as EmailFieldMeta,
|
||||||
FIELD_DEFAULT_GENERIC_ALIGN,
|
|
||||||
ZEmailFieldMeta,
|
ZEmailFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
import { Form } from '@documenso/ui/primitives/form/form';
|
import { Form } from '@documenso/ui/primitives/form/form';
|
||||||
@ -40,7 +39,7 @@ export const EditorFieldEmailForm = ({
|
|||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
|
textAlign: value.textAlign || 'left',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,6 @@ import { useEffect } from 'react';
|
|||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { type Control, useFormContext } from 'react-hook-form';
|
import { type Control, useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
import { FIELD_MIN_LINE_HEIGHT } from '@documenso/lib/types/field-meta';
|
|
||||||
import { FIELD_MAX_LINE_HEIGHT } from '@documenso/lib/types/field-meta';
|
|
||||||
import { FIELD_MIN_LETTER_SPACING } from '@documenso/lib/types/field-meta';
|
|
||||||
import { FIELD_MAX_LETTER_SPACING } from '@documenso/lib/types/field-meta';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
import {
|
import {
|
||||||
@ -111,119 +107,6 @@ export const EditorGenericTextAlignField = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditorGenericVerticalAlignField = ({
|
|
||||||
formControl,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
formControl: FormControlType;
|
|
||||||
className?: string;
|
|
||||||
}) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormField
|
|
||||||
control={formControl}
|
|
||||||
name="verticalAlign"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className={className}>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Vertical Align</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={t`Select vertical align`} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="top">
|
|
||||||
<Trans>Top</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="middle">
|
|
||||||
<Trans>Middle</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="bottom">
|
|
||||||
<Trans>Bottom</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EditorGenericLineHeightField = ({
|
|
||||||
formControl,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
formControl: FormControlType;
|
|
||||||
className?: string;
|
|
||||||
}) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormField
|
|
||||||
control={formControl}
|
|
||||||
name="lineHeight"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className={className}>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Line Height</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={FIELD_MIN_LINE_HEIGHT}
|
|
||||||
max={FIELD_MAX_LINE_HEIGHT}
|
|
||||||
className="bg-background"
|
|
||||||
placeholder={t`Line height`}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EditorGenericLetterSpacingField = ({
|
|
||||||
formControl,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
formControl: FormControlType;
|
|
||||||
className?: string;
|
|
||||||
}) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormField
|
|
||||||
control={formControl}
|
|
||||||
name="letterSpacing"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className={className}>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Letter Spacing</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={FIELD_MIN_LETTER_SPACING}
|
|
||||||
max={FIELD_MAX_LETTER_SPACING}
|
|
||||||
className="bg-background"
|
|
||||||
placeholder={t`Letter spacing`}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EditorGenericRequiredField = ({
|
export const EditorGenericRequiredField = ({
|
||||||
formControl,
|
formControl,
|
||||||
className,
|
className,
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import type { z } from 'zod';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_FIELD_FONT_SIZE,
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
FIELD_DEFAULT_GENERIC_ALIGN,
|
|
||||||
type TInitialsFieldMeta as InitialsFieldMeta,
|
type TInitialsFieldMeta as InitialsFieldMeta,
|
||||||
ZInitialsFieldMeta,
|
ZInitialsFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
@ -40,7 +39,7 @@ export const EditorFieldInitialsForm = ({
|
|||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
|
textAlign: value.textAlign || 'left',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import type { z } from 'zod';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_FIELD_FONT_SIZE,
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
FIELD_DEFAULT_GENERIC_ALIGN,
|
|
||||||
type TNameFieldMeta as NameFieldMeta,
|
type TNameFieldMeta as NameFieldMeta,
|
||||||
ZNameFieldMeta,
|
ZNameFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
@ -40,7 +39,7 @@ export const EditorFieldNameForm = ({
|
|||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
|
textAlign: value.textAlign || 'left',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -6,11 +6,6 @@ import { useForm, useWatch } from 'react-hook-form';
|
|||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_FIELD_FONT_SIZE,
|
|
||||||
FIELD_DEFAULT_GENERIC_ALIGN,
|
|
||||||
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
|
|
||||||
FIELD_DEFAULT_LETTER_SPACING,
|
|
||||||
FIELD_DEFAULT_LINE_HEIGHT,
|
|
||||||
type TNumberFieldMeta as NumberFieldMeta,
|
type TNumberFieldMeta as NumberFieldMeta,
|
||||||
ZNumberFieldMeta,
|
ZNumberFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
@ -36,12 +31,9 @@ import { Separator } from '@documenso/ui/primitives/separator';
|
|||||||
import {
|
import {
|
||||||
EditorGenericFontSizeField,
|
EditorGenericFontSizeField,
|
||||||
EditorGenericLabelField,
|
EditorGenericLabelField,
|
||||||
EditorGenericLetterSpacingField,
|
|
||||||
EditorGenericLineHeightField,
|
|
||||||
EditorGenericReadOnlyField,
|
EditorGenericReadOnlyField,
|
||||||
EditorGenericRequiredField,
|
EditorGenericRequiredField,
|
||||||
EditorGenericTextAlignField,
|
EditorGenericTextAlignField,
|
||||||
EditorGenericVerticalAlignField,
|
|
||||||
} from './editor-field-generic-field-forms';
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
|
const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
|
||||||
@ -51,9 +43,6 @@ const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
|
|||||||
numberFormat: true,
|
numberFormat: true,
|
||||||
fontSize: true,
|
fontSize: true,
|
||||||
textAlign: true,
|
textAlign: true,
|
||||||
lineHeight: true,
|
|
||||||
letterSpacing: true,
|
|
||||||
verticalAlign: true,
|
|
||||||
required: true,
|
required: true,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
minValue: true,
|
minValue: true,
|
||||||
@ -110,11 +99,8 @@ export const EditorFieldNumberForm = ({
|
|||||||
placeholder: value.placeholder || '',
|
placeholder: value.placeholder || '',
|
||||||
value: value.value || '',
|
value: value.value || '',
|
||||||
numberFormat: value.numberFormat || null,
|
numberFormat: value.numberFormat || null,
|
||||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
fontSize: value.fontSize || 14,
|
||||||
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
|
textAlign: value.textAlign || 'left',
|
||||||
lineHeight: value.lineHeight ?? FIELD_DEFAULT_LINE_HEIGHT,
|
|
||||||
letterSpacing: value.letterSpacing ?? FIELD_DEFAULT_LETTER_SPACING,
|
|
||||||
verticalAlign: value.verticalAlign ?? FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
|
|
||||||
required: value.required || false,
|
required: value.required || false,
|
||||||
readOnly: value.readOnly || false,
|
readOnly: value.readOnly || false,
|
||||||
minValue: value.minValue,
|
minValue: value.minValue,
|
||||||
@ -132,10 +118,6 @@ export const EditorFieldNumberForm = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues);
|
const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues);
|
||||||
|
|
||||||
if (formValues.readOnly && !formValues.value) {
|
|
||||||
void form.trigger('value');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validatedFormValues.success) {
|
if (validatedFormValues.success) {
|
||||||
onValueChange({
|
onValueChange({
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@ -148,12 +130,10 @@ 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} />
|
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
||||||
|
|
||||||
<div className="flex w-full flex-row gap-x-4">
|
|
||||||
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
||||||
|
|
||||||
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditorGenericLabelField formControl={form.control} />
|
<EditorGenericLabelField formControl={form.control} />
|
||||||
@ -224,12 +204,6 @@ export const EditorFieldNumberForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full flex-row gap-x-4">
|
|
||||||
<EditorGenericLineHeightField className="w-full" formControl={form.control} />
|
|
||||||
|
|
||||||
<EditorGenericLetterSpacingField 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>
|
||||||
|
|||||||
@ -5,8 +5,11 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '@documenso/lib/constants/pdf';
|
import {
|
||||||
import { type TSignatureFieldMeta, ZSignatureFieldMeta } from '@documenso/lib/types/field-meta';
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
type TSignatureFieldMeta,
|
||||||
|
ZSignatureFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
import { Form } from '@documenso/ui/primitives/form/form';
|
import { Form } from '@documenso/ui/primitives/form/form';
|
||||||
|
|
||||||
import { EditorGenericFontSizeField } from './editor-field-generic-field-forms';
|
import { EditorGenericFontSizeField } from './editor-field-generic-field-forms';
|
||||||
@ -32,7 +35,7 @@ export const EditorFieldSignatureForm = ({
|
|||||||
resolver: zodResolver(ZSignatureFieldFormSchema),
|
resolver: zodResolver(ZSignatureFieldFormSchema),
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
fontSize: value.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE,
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -3,16 +3,11 @@ import { useEffect } from 'react';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import type { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_FIELD_FONT_SIZE,
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
FIELD_DEFAULT_GENERIC_ALIGN,
|
|
||||||
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
|
|
||||||
FIELD_DEFAULT_LETTER_SPACING,
|
|
||||||
FIELD_DEFAULT_LINE_HEIGHT,
|
|
||||||
type TTextFieldMeta as TextFieldMeta,
|
type TTextFieldMeta as TextFieldMeta,
|
||||||
ZTextFieldMeta,
|
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@ -27,27 +22,23 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
EditorGenericFontSizeField,
|
EditorGenericFontSizeField,
|
||||||
EditorGenericLetterSpacingField,
|
|
||||||
EditorGenericLineHeightField,
|
|
||||||
EditorGenericReadOnlyField,
|
EditorGenericReadOnlyField,
|
||||||
EditorGenericRequiredField,
|
EditorGenericRequiredField,
|
||||||
EditorGenericTextAlignField,
|
EditorGenericTextAlignField,
|
||||||
EditorGenericVerticalAlignField,
|
|
||||||
} from './editor-field-generic-field-forms';
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
const ZTextFieldFormSchema = ZTextFieldMeta.pick({
|
const ZTextFieldFormSchema = z
|
||||||
label: true,
|
.object({
|
||||||
placeholder: true,
|
label: z.string().optional(),
|
||||||
text: true,
|
placeholder: z.string().optional(),
|
||||||
characterLimit: true,
|
text: z.string().optional(),
|
||||||
fontSize: true,
|
characterLimit: z.coerce.number().min(0).optional(),
|
||||||
textAlign: true,
|
fontSize: z.coerce.number().min(8).max(96).optional(),
|
||||||
lineHeight: true,
|
textAlign: z.enum(['left', 'center', 'right']).optional(),
|
||||||
letterSpacing: true,
|
required: z.boolean().optional(),
|
||||||
verticalAlign: true,
|
readOnly: z.boolean().optional(),
|
||||||
required: true,
|
})
|
||||||
readOnly: true,
|
.refine(
|
||||||
}).refine(
|
|
||||||
(data) => {
|
(data) => {
|
||||||
// A read-only field must have text
|
// A read-only field must have text
|
||||||
return !data.readOnly || (data.text && data.text.length > 0);
|
return !data.readOnly || (data.text && data.text.length > 0);
|
||||||
@ -82,10 +73,7 @@ export const EditorFieldTextForm = ({
|
|||||||
text: value.text || '',
|
text: value.text || '',
|
||||||
characterLimit: value.characterLimit || 0,
|
characterLimit: value.characterLimit || 0,
|
||||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
|
textAlign: value.textAlign || 'left',
|
||||||
lineHeight: value.lineHeight ?? FIELD_DEFAULT_LINE_HEIGHT,
|
|
||||||
letterSpacing: value.letterSpacing ?? FIELD_DEFAULT_LETTER_SPACING,
|
|
||||||
verticalAlign: value.verticalAlign ?? FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
|
|
||||||
required: value.required || false,
|
required: value.required || false,
|
||||||
readOnly: value.readOnly || false,
|
readOnly: value.readOnly || false,
|
||||||
},
|
},
|
||||||
@ -101,10 +89,6 @@ export const EditorFieldTextForm = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues);
|
const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues);
|
||||||
|
|
||||||
if (formValues.readOnly && !formValues.text) {
|
|
||||||
void form.trigger('text');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validatedFormValues.success) {
|
if (validatedFormValues.success) {
|
||||||
onValueChange({
|
onValueChange({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@ -117,12 +101,10 @@ 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} />
|
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
||||||
|
|
||||||
<div className="flex w-full flex-row gap-x-4">
|
|
||||||
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
||||||
|
|
||||||
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@ -170,18 +152,6 @@ 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>
|
||||||
@ -200,22 +170,11 @@ export const EditorFieldTextForm = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
className="bg-background"
|
className="bg-background"
|
||||||
placeholder={t`Character limit`}
|
placeholder={t`Field character limit`}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const values = form.getValues();
|
|
||||||
const characterLimit = parseInt(e.target.value, 10) || 0;
|
|
||||||
|
|
||||||
field.onChange(characterLimit || '');
|
|
||||||
|
|
||||||
const textValue = values.text || '';
|
|
||||||
|
|
||||||
if (characterLimit > 0 && textValue.length > characterLimit) {
|
|
||||||
form.setValue('text', textValue.slice(0, characterLimit));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -223,12 +182,6 @@ export const EditorFieldTextForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full flex-row gap-x-4">
|
|
||||||
<EditorGenericLineHeightField className="w-full" formControl={form.control} />
|
|
||||||
|
|
||||||
<EditorGenericLetterSpacingField 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>
|
||||||
|
|||||||
@ -92,7 +92,6 @@ 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'
|
||||||
@ -318,8 +317,6 @@ export const SignInForm = ({
|
|||||||
if (email) {
|
if (email) {
|
||||||
form.setValue('email', email);
|
form.setValue('email', email);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -386,8 +383,6 @@ export const SignInForm = ({
|
|||||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!isEmbeddedRedirect && (
|
|
||||||
<>
|
|
||||||
{hasSocialAuthEnabled && (
|
{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" />
|
||||||
@ -421,11 +416,7 @@ export const SignInForm = ({
|
|||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithMicrosoftClick}
|
onClick={onSignInWithMicrosoftClick}
|
||||||
>
|
>
|
||||||
<img
|
<img className="mr-2 h-4 w-4" alt="Microsoft Logo" src={'/static/microsoft.svg'} />
|
||||||
className="mr-2 h-4 w-4"
|
|
||||||
alt="Microsoft Logo"
|
|
||||||
src={'/static/microsoft.svg'}
|
|
||||||
/>
|
|
||||||
Microsoft
|
Microsoft
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@ -443,8 +434,6 @@ export const SignInForm = ({
|
|||||||
{oidcProviderLabel || 'OIDC'}
|
{oidcProviderLabel || 'OIDC'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -68,7 +68,6 @@ export type SignUpFormProps = {
|
|||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
isMicrosoftSSOEnabled?: boolean;
|
isMicrosoftSSOEnabled?: boolean;
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
returnTo?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignUpForm = ({
|
export const SignUpForm = ({
|
||||||
@ -77,7 +76,6 @@ export const SignUpForm = ({
|
|||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
isMicrosoftSSOEnabled,
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
returnTo,
|
|
||||||
}: SignUpFormProps) => {
|
}: SignUpFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -112,7 +110,7 @@ export const SignUpForm = ({
|
|||||||
signature,
|
signature,
|
||||||
});
|
});
|
||||||
|
|
||||||
await navigate(returnTo ? returnTo : '/unverified-account');
|
await navigate(`/unverified-account`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Registration Successful`),
|
title: _(msg`Registration Successful`),
|
||||||
|
|||||||
@ -9,7 +9,6 @@ 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 {
|
||||||
@ -64,12 +63,10 @@ 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: debouncedSearch,
|
query: search,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
@ -235,7 +232,6 @@ 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 && (
|
||||||
@ -243,17 +239,14 @@ 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
|
||||||
@ -262,7 +255,6 @@ 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} />
|
||||||
|
|||||||
@ -153,9 +153,7 @@ export const DirectTemplatePageView = ({
|
|||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
key={template.id}
|
key={template.id}
|
||||||
envelopeItem={template.envelopeItems[0]}
|
documentData={template.templateDocumentData}
|
||||||
token={directTemplateRecipient.token}
|
|
||||||
version="signed"
|
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
actionVerb = 'sign',
|
actionVerb = 'sign',
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DocumentSigningAuthAccountProps) => {
|
}: DocumentSigningAuthAccountProps) => {
|
||||||
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
|
const { recipient } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
@ -34,10 +34,8 @@ 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?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
redirectPath: `/signin#email=${email}`,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setIsSigningOut(false);
|
setIsSigningOut(false);
|
||||||
@ -57,28 +55,16 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||||
<span>
|
<span>
|
||||||
{isDirectTemplate ? (
|
|
||||||
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>
|
<Trans>
|
||||||
To mark this document as viewed, you need to be logged in as{' '}
|
To mark this document as viewed, you need to be logged in as{' '}
|
||||||
<strong>{recipient.email}</strong>
|
<strong>{recipient.email}</strong>
|
||||||
</Trans>
|
</Trans>
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
{isDirectTemplate ? (
|
{/* Todo: Translate */}
|
||||||
<Trans>
|
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
|
||||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
in as <strong>{recipient.email}</strong>
|
||||||
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,8 +47,7 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onReauthFormSubmit,
|
onReauthFormSubmit,
|
||||||
}: DocumentSigningAuthDialogProps) => {
|
}: DocumentSigningAuthDialogProps) => {
|
||||||
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
|
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
|
||||||
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(
|
||||||
@ -169,11 +168,7 @@ 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 }, () => (
|
||||||
|
|||||||
@ -40,7 +40,6 @@ 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;
|
||||||
@ -69,7 +68,6 @@ 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;
|
||||||
}
|
}
|
||||||
@ -77,7 +75,6 @@ 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) => {
|
||||||
@ -207,7 +204,6 @@ export const DocumentSigningAuthProvider = ({
|
|||||||
derivedRecipientAccessAuth,
|
derivedRecipientAccessAuth,
|
||||||
derivedRecipientActionAuth,
|
derivedRecipientActionAuth,
|
||||||
isAuthRedirectRequired,
|
isAuthRedirectRequired,
|
||||||
isDirectTemplate,
|
|
||||||
isCurrentlyAuthenticating,
|
isCurrentlyAuthenticating,
|
||||||
setIsCurrentlyAuthenticating,
|
setIsCurrentlyAuthenticating,
|
||||||
passkeyData,
|
passkeyData,
|
||||||
|
|||||||
@ -34,7 +34,6 @@ 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';
|
||||||
|
|
||||||
@ -103,8 +102,6 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
|
|
||||||
const { derivedRecipientAccessAuth } = 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,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -270,12 +267,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
<Trans>Your Name</Trans>
|
<Trans>Your Name</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} className="mt-2" placeholder={t`Enter your name`} />
|
||||||
{...field}
|
|
||||||
className="mt-2"
|
|
||||||
placeholder={t`Enter your name`}
|
|
||||||
disabled={isNameLocked}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -297,7 +289,6 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
type="email"
|
type="email"
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
placeholder={t`Enter your email`}
|
placeholder={t`Enter your email`}
|
||||||
disabled={!!field.value && isEmailLocked}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@ -8,9 +8,6 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
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 EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||||
import { EnvelopeSignerCompleteDialog } from '../envelope-signing/envelope-signing-complete-dialog';
|
import { EnvelopeSignerCompleteDialog } from '../envelope-signing/envelope-signing-complete-dialog';
|
||||||
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
||||||
@ -18,8 +15,6 @@ import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
|||||||
export const DocumentSigningMobileWidget = () => {
|
export const DocumentSigningMobileWidget = () => {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
const { hidePoweredBy = true } = useEmbedSigningContext() || {};
|
|
||||||
|
|
||||||
const { recipientFieldsRemaining, recipient, requiredRecipientFields } =
|
const { recipientFieldsRemaining, recipient, requiredRecipientFields } =
|
||||||
useRequiredEnvelopeSigningContext();
|
useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
@ -34,7 +29,7 @@ export const DocumentSigningMobileWidget = () => {
|
|||||||
|
|
||||||
return (
|
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-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="pointer-events-auto w-full max-w-2xl">
|
||||||
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-2xl">
|
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-2xl">
|
||||||
{/* Main Header Bar */}
|
{/* Main Header Bar */}
|
||||||
<div className="flex items-center justify-between gap-4 p-4">
|
<div className="flex items-center justify-between gap-4 p-4">
|
||||||
@ -119,13 +114,6 @@ export const DocumentSigningMobileWidget = () => {
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-border animate-in slide-in-from-bottom-2 border-t p-4 duration-200">
|
<div className="border-border animate-in slide-in-from-bottom-2 border-t p-4 duration-200">
|
||||||
<EnvelopeSignerForm />
|
<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>
|
||||||
|
|||||||
@ -245,12 +245,7 @@ export const DocumentSigningPageViewV1 = ({
|
|||||||
<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
|
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
||||||
key={document.envelopeItems[0].id}
|
|
||||||
envelopeItem={document.envelopeItems[0]}
|
|
||||||
token={recipient.token}
|
|
||||||
version="signed"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,9 +22,7 @@ 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 { 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';
|
||||||
@ -50,13 +48,6 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
selectedAssistantRecipientFields,
|
selectedAssistantRecipientFields,
|
||||||
} = useRequiredEnvelopeSigningContext();
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
const {
|
|
||||||
isEmbed = false,
|
|
||||||
allowDocumentRejection = true,
|
|
||||||
hidePoweredBy = true,
|
|
||||||
onDocumentRejected,
|
|
||||||
} = useEmbedSigningContext() || {};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The total remaining fields remaining for the current recipient or selected assistant recipient.
|
* The total remaining fields remaining for the current recipient or selected assistant recipient.
|
||||||
*
|
*
|
||||||
@ -86,7 +77,7 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||||
{/* Left Section - Step Navigation */}
|
{/* Left Section - Step Navigation */}
|
||||||
<div className="embed--DocumentWidgetContainer bg-background border-border hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4 lg:flex">
|
<div className="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="text-foreground flex items-end justify-between text-sm font-semibold">
|
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
|
||||||
{match(recipient.role)
|
{match(recipient.role)
|
||||||
@ -116,7 +107,7 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
|
<div className="mt-6 space-y-3">
|
||||||
<EnvelopeSignerForm />
|
<EnvelopeSignerForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -125,7 +116,7 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
|
|
||||||
{/* Quick Actions. */}
|
{/* Quick Actions. */}
|
||||||
{!isDirectTemplate && (
|
{!isDirectTemplate && (
|
||||||
<div className="embed--Actions space-y-3 px-4">
|
<div className="space-y-3 px-4">
|
||||||
<h4 className="text-foreground text-sm font-semibold">
|
<h4 className="text-foreground text-sm font-semibold">
|
||||||
<Trans>Actions</Trans>
|
<Trans>Actions</Trans>
|
||||||
</h4>
|
</h4>
|
||||||
@ -154,21 +145,10 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
|
{envelope.type === EnvelopeType.DOCUMENT && (
|
||||||
<DocumentSigningRejectDialog
|
<DocumentSigningRejectDialog
|
||||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
token={recipient.token}
|
token={recipient.token}
|
||||||
onRejected={
|
|
||||||
onDocumentRejected &&
|
|
||||||
((reason) =>
|
|
||||||
onDocumentRejected({
|
|
||||||
token: recipient.token,
|
|
||||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
|
||||||
envelopeId: envelope.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
reason,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -184,10 +164,8 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="embed--DocumentWidgetFooter mt-auto">
|
|
||||||
{/* Footer of left sidebar. */}
|
{/* Footer of left sidebar. */}
|
||||||
{!isEmbed && (
|
<div className="mt-auto px-4">
|
||||||
<div className="px-4">
|
|
||||||
<Button asChild variant="ghost" className="w-full justify-start">
|
<Button asChild variant="ghost" className="w-full justify-start">
|
||||||
<Link to="/">
|
<Link to="/">
|
||||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||||
@ -195,11 +173,9 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 */}
|
||||||
{envelopeItems.length > 1 && (
|
{envelopeItems.length > 1 && (
|
||||||
@ -226,11 +202,11 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Document View */}
|
{/* Document View */}
|
||||||
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
<div className="flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
||||||
{currentEnvelopeItem ? (
|
{currentEnvelopeItem ? (
|
||||||
<PDFViewerKonvaLazy
|
<PDFViewerKonvaLazy
|
||||||
renderer="signing"
|
|
||||||
key={currentEnvelopeItem.id}
|
key={currentEnvelopeItem.id}
|
||||||
|
documentDataId={currentEnvelopeItem.documentDataId}
|
||||||
customPageRenderer={EnvelopeSignerPageRenderer}
|
customPageRenderer={EnvelopeSignerPageRenderer}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -242,20 +218,9 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile widget - Additional padding to allow users to scroll */}
|
{/* Mobile widget - Additional padding to allow users to scroll */}
|
||||||
<div className="block pb-28 lg:hidden">
|
<div className="block pb-16 md:hidden">
|
||||||
<DocumentSigningMobileWidget />
|
<DocumentSigningMobileWidget />
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@ -13,7 +13,6 @@ 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 type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
|
||||||
import {
|
import {
|
||||||
isFieldUnsignedAndRequired,
|
isFieldUnsignedAndRequired,
|
||||||
isRequiredField,
|
isRequiredField,
|
||||||
@ -52,11 +51,7 @@ 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: (
|
signField: (_fieldId: number, _value: TSignEnvelopeFieldValue) => Promise<void>;
|
||||||
_fieldId: number,
|
|
||||||
_value: TSignEnvelopeFieldValue,
|
|
||||||
authOptions?: TRecipientActionAuth,
|
|
||||||
) => Promise<Pick<Field, 'id' | 'inserted'>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
|
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
|
||||||
@ -289,26 +284,19 @@ export const EnvelopeSigningProvider = ({
|
|||||||
: null;
|
: null;
|
||||||
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
|
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
|
||||||
|
|
||||||
const signField = async (
|
const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
|
||||||
fieldId: number,
|
|
||||||
fieldValue: TSignEnvelopeFieldValue,
|
|
||||||
authOptions?: TRecipientActionAuth,
|
|
||||||
) => {
|
|
||||||
// Set the field locally for direct templates.
|
// Set the field locally for direct templates.
|
||||||
if (isDirectTemplate) {
|
if (isDirectTemplate) {
|
||||||
const signedField = handleDirectTemplateFieldInsertion(fieldId, fieldValue);
|
handleDirectTemplateFieldInsertion(fieldId, fieldValue);
|
||||||
|
return;
|
||||||
return signedField;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { signedField } = await signEnvelopeField({
|
await signEnvelopeField({
|
||||||
token: envelopeData.recipient.token,
|
token: envelopeData.recipient.token,
|
||||||
fieldId,
|
fieldId,
|
||||||
fieldValue,
|
fieldValue,
|
||||||
authOptions,
|
authOptions: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return signedField;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDirectTemplateFieldInsertion = (
|
const handleDirectTemplateFieldInsertion = (
|
||||||
@ -366,8 +354,6 @@ export const EnvelopeSigningProvider = ({
|
|||||||
fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)),
|
fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return updatedField;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
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, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
|
import type { DocumentData, EnvelopeItem } from '@prisma/client';
|
||||||
import { DownloadIcon } from 'lucide-react';
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -23,10 +22,9 @@ 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;
|
||||||
@ -36,7 +34,6 @@ export type DocumentCertificateQRViewProps = {
|
|||||||
documentTeamUrl: string;
|
documentTeamUrl: string;
|
||||||
recipientCount?: number;
|
recipientCount?: number;
|
||||||
completedDate?: Date;
|
completedDate?: Date;
|
||||||
token: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentCertificateQRView = ({
|
export const DocumentCertificateQRView = ({
|
||||||
@ -47,7 +44,6 @@ 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,
|
||||||
@ -100,19 +96,11 @@ export const DocumentCertificateQRView = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{internalVersion === 2 ? (
|
{internalVersion === 2 ? (
|
||||||
<EnvelopeRenderProvider
|
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
|
||||||
envelope={{
|
|
||||||
envelopeItems,
|
|
||||||
status: DocumentStatus.COMPLETED,
|
|
||||||
type: EnvelopeType.DOCUMENT,
|
|
||||||
}}
|
|
||||||
token={token}
|
|
||||||
>
|
|
||||||
<DocumentCertificateQrV2
|
<DocumentCertificateQrV2
|
||||||
title={title}
|
title={title}
|
||||||
recipientCount={recipientCount}
|
recipientCount={recipientCount}
|
||||||
formattedDate={formattedDate}
|
formattedDate={formattedDate}
|
||||||
token={token}
|
|
||||||
/>
|
/>
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
) : (
|
) : (
|
||||||
@ -131,27 +119,14 @@ export const DocumentCertificateQRView = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EnvelopeDownloadDialog
|
<ShareDocumentDownloadButton
|
||||||
envelopeId={envelopeItems[0].envelopeId}
|
title={title}
|
||||||
envelopeStatus={DocumentStatus.COMPLETED}
|
documentData={envelopeItems[0].documentData}
|
||||||
envelopeItems={envelopeItems}
|
|
||||||
token={token}
|
|
||||||
trigger={
|
|
||||||
<Button type="button" variant="outline" className="w-fit">
|
|
||||||
<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">
|
||||||
<PDFViewer
|
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
|
||||||
key={envelopeItems[0].id}
|
|
||||||
envelopeItem={envelopeItems[0]}
|
|
||||||
token={token}
|
|
||||||
version="signed"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -163,16 +138,14 @@ type DocumentCertificateQrV2Props = {
|
|||||||
title: string;
|
title: string;
|
||||||
recipientCount: number;
|
recipientCount: number;
|
||||||
formattedDate: string;
|
formattedDate: string;
|
||||||
token: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DocumentCertificateQrV2 = ({
|
const DocumentCertificateQrV2 = ({
|
||||||
title,
|
title,
|
||||||
recipientCount,
|
recipientCount,
|
||||||
formattedDate,
|
formattedDate,
|
||||||
token,
|
|
||||||
}: DocumentCertificateQrV2Props) => {
|
}: DocumentCertificateQrV2Props) => {
|
||||||
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-start">
|
<div className="flex min-h-screen flex-col items-start">
|
||||||
@ -190,24 +163,18 @@ const DocumentCertificateQrV2 = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EnvelopeDownloadDialog
|
{currentEnvelopeItem && (
|
||||||
envelopeId={envelopeItems[0].envelopeId}
|
<ShareDocumentDownloadButton
|
||||||
envelopeStatus={DocumentStatus.COMPLETED}
|
title={title}
|
||||||
envelopeItems={envelopeItems}
|
documentData={currentEnvelopeItem.documentData}
|
||||||
token={token}
|
|
||||||
trigger={
|
|
||||||
<Button type="button" variant="outline" className="w-fit">
|
|
||||||
<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">
|
||||||
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
||||||
|
|
||||||
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
|
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,15 +1,10 @@
|
|||||||
import { type ReactNode, useState } from 'react';
|
import { type ReactNode, useState } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import {
|
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
||||||
ErrorCode as DropzoneErrorCode,
|
|
||||||
ErrorCode,
|
|
||||||
type FileRejection,
|
|
||||||
useDropzone,
|
|
||||||
} from 'react-dropzone';
|
|
||||||
import { Link, useNavigate, useParams } from 'react-router';
|
import { Link, useNavigate, useParams } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -21,26 +16,21 @@ 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 { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { formatDocumentsPath } 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 { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export interface EnvelopeDropZoneWrapperProps {
|
export interface DocumentDropZoneWrapperProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
type: EnvelopeType;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EnvelopeDropZoneWrapper = ({
|
export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZoneWrapperProps) => {
|
||||||
children,
|
const { _ } = useLingui();
|
||||||
type,
|
|
||||||
className,
|
|
||||||
}: EnvelopeDropZoneWrapperProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const { folderId } = useParams();
|
const { folderId } = useParams();
|
||||||
@ -57,13 +47,13 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
|
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
|
||||||
DEFAULT_DOCUMENT_TIME_ZONE;
|
DEFAULT_DOCUMENT_TIME_ZONE;
|
||||||
|
|
||||||
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
|
const { quota, remaining, refreshLimits } = useLimits();
|
||||||
|
|
||||||
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
|
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
|
||||||
|
|
||||||
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
|
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
|
||||||
|
|
||||||
const onFileDrop = async (files: File[]) => {
|
const onFileDrop = async (file: File) => {
|
||||||
if (isUploadDisabled && IS_BILLING_ENABLED()) {
|
if (isUploadDisabled && IS_BILLING_ENABLED()) {
|
||||||
await navigate(`/o/${organisation.url}/settings/billing`);
|
await navigate(`/o/${organisation.url}/settings/billing`);
|
||||||
return;
|
return;
|
||||||
@ -72,68 +62,48 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const payload = {
|
const response = await putPdfFile(file);
|
||||||
folderId,
|
|
||||||
type,
|
|
||||||
title: files[0].name,
|
|
||||||
meta: {
|
|
||||||
timezone: userTimezone,
|
|
||||||
},
|
|
||||||
} satisfies TCreateEnvelopePayload;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
const { legacyDocumentId: id } = await createDocument({
|
||||||
|
title: file.name,
|
||||||
formData.append('payload', JSON.stringify(payload));
|
documentDataId: response.id,
|
||||||
|
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
|
||||||
for (const file of files) {
|
folderId: folderId ?? undefined,
|
||||||
formData.append('files', file);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await createEnvelope(formData);
|
|
||||||
|
|
||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
|
title: _(msg`Document uploaded`),
|
||||||
description:
|
description: _(msg`Your document has been uploaded successfully.`),
|
||||||
type === EnvelopeType.DOCUMENT
|
|
||||||
? t`Your document has been uploaded successfully.`
|
|
||||||
: t`Your template has been uploaded successfully.`,
|
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (type === EnvelopeType.DOCUMENT) {
|
|
||||||
analytics.capture('App: Document Uploaded', {
|
analytics.capture('App: Document Uploaded', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
documentId: id,
|
documentId: id,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const pathPrefix =
|
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
|
||||||
type === EnvelopeType.DOCUMENT
|
|
||||||
? formatDocumentsPath(team.url)
|
|
||||||
: formatTemplatesPath(team.url);
|
|
||||||
|
|
||||||
await navigate(`${pathPrefix}/${id}/edit`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
const errorMessage = match(error.code)
|
const errorMessage = match(error.code)
|
||||||
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
|
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`)
|
||||||
.with(
|
.with(
|
||||||
AppErrorCode.LIMIT_EXCEEDED,
|
AppErrorCode.LIMIT_EXCEEDED,
|
||||||
() => t`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(
|
.with(
|
||||||
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||||
() => t`You have reached the limit of the number of files per envelope`,
|
() => msg`You have reached the limit of the number of files per envelope`,
|
||||||
)
|
)
|
||||||
.otherwise(() => t`An error occurred during upload.`);
|
.otherwise(() => msg`An error occurred while uploading your document.`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Error`,
|
title: _(msg`Error`),
|
||||||
description: errorMessage,
|
description: _(errorMessage),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
@ -147,20 +117,6 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
||||||
const { file, errors } = fileRejections[0];
|
const { file, errors } = fileRejections[0];
|
||||||
|
|
||||||
@ -188,14 +144,14 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
const description = (
|
const description = (
|
||||||
<>
|
<>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
<Trans>{file.name} couldn't be uploaded:</Trans>
|
{file.name} <Trans>couldn't be uploaded:</Trans>
|
||||||
</span>
|
</span>
|
||||||
{errorNodes}
|
{errorNodes}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Upload failed`,
|
title: _(msg`Upload failed`),
|
||||||
description,
|
description,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
@ -205,11 +161,17 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
accept: {
|
accept: {
|
||||||
'application/pdf': ['.pdf'],
|
'application/pdf': ['.pdf'],
|
||||||
},
|
},
|
||||||
multiple: true,
|
//disabled: isUploadDisabled,
|
||||||
|
multiple: false,
|
||||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||||
maxFiles: maximumEnvelopeItemCount,
|
onDrop: ([acceptedFile]) => {
|
||||||
onDrop: (files) => void onFileDrop(files),
|
if (acceptedFile) {
|
||||||
onDropRejected: onFileDropRejected,
|
void onFileDrop(acceptedFile);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDropRejected: (fileRejections) => {
|
||||||
|
onFileDropRejected(fileRejections);
|
||||||
|
},
|
||||||
noClick: true,
|
noClick: true,
|
||||||
noDragEventsBubbling: true,
|
noDragEventsBubbling: true,
|
||||||
});
|
});
|
||||||
@ -223,11 +185,7 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
||||||
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
||||||
<h2 className="text-foreground text-2xl font-semibold">
|
<h2 className="text-foreground text-2xl font-semibold">
|
||||||
{type === EnvelopeType.DOCUMENT ? (
|
|
||||||
<Trans>Upload Document</Trans>
|
<Trans>Upload Document</Trans>
|
||||||
) : (
|
|
||||||
<Trans>Upload Template</Trans>
|
|
||||||
)}
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-md mt-4">
|
<p className="text-muted-foreground text-md mt-4">
|
||||||
@ -262,7 +220,7 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
||||||
<Loader className="text-primary h-12 w-12 animate-spin" />
|
<Loader className="text-primary h-12 w-12 animate-spin" />
|
||||||
<p className="text-foreground mt-8 font-medium">
|
<p className="text-foreground mt-8 font-medium">
|
||||||
<Trans>Uploading</Trans>
|
<Trans>Uploading document...</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -441,10 +441,9 @@ export const DocumentEditForm = ({
|
|||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
key={document.envelopeItems[0].id}
|
key={document.documentData.id}
|
||||||
envelopeItem={document.envelopeItems[0]}
|
documentData={document.documentData}
|
||||||
token={undefined}
|
document={document}
|
||||||
version="signed"
|
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
|
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';
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
|
|
||||||
@ -19,6 +23,9 @@ 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;
|
||||||
@ -30,6 +37,25 @@ 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,
|
||||||
@ -69,7 +95,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true, internalVersion: 2 }, () => (
|
||||||
<EnvelopeDownloadDialog
|
<EnvelopeDownloadDialog
|
||||||
envelopeId={envelope.id}
|
envelopeId={envelope.id}
|
||||||
envelopeStatus={envelope.status}
|
envelopeStatus={envelope.status}
|
||||||
@ -83,5 +109,11 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
.with({ isComplete: true }, () => (
|
||||||
|
<Button className="w-full" onClick={onDownloadClick}>
|
||||||
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
.otherwise(() => null);
|
.otherwise(() => null);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
@ -15,11 +16,13 @@ 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,
|
||||||
@ -64,6 +67,64 @@ 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 (
|
||||||
@ -86,6 +147,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{envelope.internalVersion === 2 ? (
|
||||||
<EnvelopeDownloadDialog
|
<EnvelopeDownloadDialog
|
||||||
envelopeId={envelope.id}
|
envelopeId={envelope.id}
|
||||||
envelopeStatus={envelope.status}
|
envelopeStatus={envelope.status}
|
||||||
@ -100,6 +162,21 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isComplete && (
|
||||||
|
<DropdownMenuItem onClick={onDownloadClick}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Download Original</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={`${documentsPath}/${envelope.id}/logs`}>
|
<Link to={`${documentsPath}/${envelope.id}/logs`}>
|
||||||
@ -173,8 +250,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
|||||||
|
|
||||||
{isDuplicateDialogOpen && (
|
{isDuplicateDialogOpen && (
|
||||||
<DocumentDuplicateDialog
|
<DocumentDuplicateDialog
|
||||||
id={envelope.id}
|
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
token={recipient?.token}
|
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { DateTime } from 'luxon';
|
|||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
|
||||||
|
|
||||||
export type DocumentPageViewInformationProps = {
|
export type DocumentPageViewInformationProps = {
|
||||||
userId: number;
|
userId: number;
|
||||||
@ -41,10 +40,6 @@ export const DocumentPageViewInformation = ({
|
|||||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
.toRelative(),
|
.toRelative(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
description: msg`Document ID (Legacy)`,
|
|
||||||
value: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isMounted, envelope, userId]);
|
}, [isMounted, envelope, userId]);
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { 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 { EnvelopeType } from '@prisma/client';
|
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -14,11 +13,11 @@ 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 { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@ -29,11 +28,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentUploadButtonLegacyProps = {
|
export type DocumentUploadButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLegacyProps) => {
|
export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
@ -74,20 +73,14 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const payload = {
|
const response = await putPdfFile(file);
|
||||||
|
|
||||||
|
const { legacyDocumentId: id } = await createDocument({
|
||||||
title: file.name,
|
title: file.name,
|
||||||
folderId: folderId ?? undefined,
|
documentDataId: response.id,
|
||||||
meta: {
|
|
||||||
timezone: userTimezone,
|
timezone: userTimezone,
|
||||||
},
|
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();
|
||||||
|
|
||||||
@ -147,14 +140,12 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
<DocumentUploadButtonPrimitive
|
<DocumentDropzone
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||||
disabledMessage={disabledMessage}
|
disabledMessage={disabledMessage}
|
||||||
onDrop={async (files) => onFileDrop(files[0])}
|
onDrop={async (files) => onFileDrop(files[0])}
|
||||||
onDropRejected={onFileDropRejected}
|
onDropRejected={onFileDropRejected}
|
||||||
type={EnvelopeType.DOCUMENT}
|
|
||||||
internalVersion="1"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@ -14,11 +14,11 @@ 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 { DocumentUploadButton } from '@documenso/ui/primitives/document-upload-button';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@ -78,24 +78,35 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const payload = {
|
const result = await Promise.all(
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
} satisfies TCreateEnvelopePayload;
|
}).catch((error) => {
|
||||||
|
|
||||||
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;
|
||||||
@ -175,14 +186,13 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
<DocumentUploadButton
|
<DocumentDropzone
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||||
disabledMessage={disabledMessage}
|
disabledMessage={disabledMessage}
|
||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
onDropRejected={onFileDropRejected}
|
onDropRejected={onFileDropRejected}
|
||||||
type={type}
|
type="envelope"
|
||||||
internalVersion="2"
|
|
||||||
maxFiles={maximumEnvelopeItemCount}
|
maxFiles={maximumEnvelopeItemCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -26,7 +26,7 @@ import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
|||||||
export default function EnvelopeEditorFieldsPageRenderer() {
|
export default function EnvelopeEditorFieldsPageRenderer() {
|
||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const interactiveTransformer = useRef<Transformer | null>(null);
|
const interactiveTransformer = useRef<Transformer | null>(null);
|
||||||
|
|
||||||
@ -103,6 +103,7 @@ 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.
|
||||||
@ -113,7 +114,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
pageLayer.current?.batchDraw();
|
pageLayer.current?.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
const unsafeRenderFieldOnLayer = (field: TLocalField) => {
|
const renderFieldOnLayer = (field: TLocalField) => {
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -159,15 +160,6 @@ 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Konva page canvas and all fields and interactions.
|
* Initialize the Konva page canvas and all fields and interactions.
|
||||||
*/
|
*/
|
||||||
@ -616,14 +608,13 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
}}
|
}}
|
||||||
// Don't use darkmode for this component, it should look the same for both light/dark modes.
|
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"
|
||||||
className="grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border border-gray-300 bg-white p-1 text-gray-500 shadow-sm"
|
|
||||||
>
|
>
|
||||||
{fieldButtonList.map((field) => (
|
{fieldButtonList.map((field) => (
|
||||||
<button
|
<button
|
||||||
key={field.type}
|
key={field.type}
|
||||||
onClick={() => createFieldFromPendingTemplate(pendingFieldCreation, field.type)}
|
onClick={() => createFieldFromPendingTemplate(pendingFieldCreation, field.type)}
|
||||||
className="col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100 hover:text-gray-600"
|
className="hover:text-foreground col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
{t(field.name)}
|
{t(field.name)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -27,8 +27,7 @@ import type {
|
|||||||
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, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription } 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';
|
||||||
|
|
||||||
@ -113,34 +112,9 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||||
|
|
||||||
{/* Document View */}
|
{/* Document View */}
|
||||||
<div className="mt-4 flex flex-col items-center justify-center">
|
<div className="mt-4 flex h-full justify-center p-4">
|
||||||
{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
|
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
|
||||||
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" />
|
||||||
@ -156,7 +130,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Section - Form Fields Panel */}
|
{/* Right Section - Form Fields Panel */}
|
||||||
{currentEnvelopeItem && envelope.recipients.length > 0 && (
|
{currentEnvelopeItem && (
|
||||||
<div className="bg-background border-border sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l py-4">
|
<div className="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">
|
||||||
@ -164,6 +138,19 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
<Trans>Selected Recipient</Trans>
|
<Trans>Selected Recipient</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{envelope.recipients.length === 0 ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription className="flex flex-col gap-2">
|
||||||
|
<Trans>You need at least one recipient to add fields</Trans>
|
||||||
|
|
||||||
|
<Link to={`${relativePath.editorPath}`} className="text-sm">
|
||||||
|
<p>
|
||||||
|
<Trans>Click here to add a recipient</Trans>
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
<RecipientSelector
|
<RecipientSelector
|
||||||
selectedRecipient={editorFields.selectedRecipient}
|
selectedRecipient={editorFields.selectedRecipient}
|
||||||
onSelectedRecipientChange={(recipient) =>
|
onSelectedRecipientChange={(recipient) =>
|
||||||
@ -173,6 +160,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
align="end"
|
align="end"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{editorFields.selectedRecipient &&
|
{editorFields.selectedRecipient &&
|
||||||
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
|
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
|
||||||
|
|||||||
@ -1,20 +1,10 @@
|
|||||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
import { lazy, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { faker } from '@faker-js/faker/locale/en';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { FieldType, SigningStatus } from '@prisma/client';
|
import { ConstructionIcon, FileTextIcon } from 'lucide-react';
|
||||||
import { FileTextIcon } from 'lucide-react';
|
|
||||||
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 {
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
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 { 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, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
@ -25,169 +15,15 @@ import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
|||||||
|
|
||||||
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
|
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
|
||||||
|
|
||||||
// Todo: Envelopes - Dynamically import faker
|
|
||||||
export const EnvelopeEditorPreviewPage = () => {
|
export const EnvelopeEditorPreviewPage = () => {
|
||||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
||||||
'recipient',
|
'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.
|
* Set the selected recipient to the first recipient in the envelope.
|
||||||
*/
|
*/
|
||||||
@ -195,20 +31,7 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null);
|
editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Override the parent renderer provider so we can inject custom fields.
|
|
||||||
return (
|
return (
|
||||||
<EnvelopeRenderProvider
|
|
||||||
envelope={envelope}
|
|
||||||
token={undefined}
|
|
||||||
fields={fieldsWithPlaceholders}
|
|
||||||
recipients={envelope.recipients.map((recipient) => ({
|
|
||||||
...recipient,
|
|
||||||
signingStatus: SigningStatus.SIGNED,
|
|
||||||
}))}
|
|
||||||
overrideSettings={{
|
|
||||||
mode: 'export',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="relative flex h-full">
|
<div className="relative flex h-full">
|
||||||
<div className="flex w-full flex-col overflow-y-auto">
|
<div className="flex w-full flex-col overflow-y-auto">
|
||||||
{/* Horizontal envelope item selector */}
|
{/* Horizontal envelope item selector */}
|
||||||
@ -225,11 +48,23 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
{/* Coming soon section */}
|
||||||
|
<div className="border-border bg-card hover:bg-accent/10 flex w-full max-w-[800px] items-center gap-4 rounded-lg border p-4 transition-colors">
|
||||||
|
<div className="flex w-full flex-col items-center justify-center gap-2 py-32">
|
||||||
|
<ConstructionIcon className="text-muted-foreground h-10 w-10" />
|
||||||
|
<h3 className="text-foreground text-sm font-semibold">
|
||||||
|
<Trans>Coming soon</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<Trans>This feature is coming soon</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Todo: Envelopes - Remove div after preview mode is implemented */}
|
||||||
|
<div className="hidden">
|
||||||
{currentEnvelopeItem !== null ? (
|
{currentEnvelopeItem !== null ? (
|
||||||
<PDFViewerKonvaLazy
|
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||||
renderer="editor"
|
|
||||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<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" />
|
||||||
@ -243,6 +78,7 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Right Section - Form Fields Panel */}
|
{/* Right Section - Form Fields Panel */}
|
||||||
{currentEnvelopeItem && false && (
|
{currentEnvelopeItem && false && (
|
||||||
@ -258,9 +94,7 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
<Trans>Preview Mode</Trans>
|
<Trans>Preview Mode</Trans>
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<Trans>
|
<Trans>Preview what the signed document will look like with placeholder data</Trans>
|
||||||
Preview what the signed document will look like with placeholder data
|
|
||||||
</Trans>
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
@ -334,6 +168,5 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</EnvelopeRenderProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -212,7 +212,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasDocumentBeenSent = recipients.some(
|
const hasDocumentBeenSent = recipients.some(
|
||||||
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
|
(recipient) => recipient.sendStatus === SendStatus.SENT,
|
||||||
);
|
);
|
||||||
|
|
||||||
const canRecipientBeModified = (recipientId?: number) => {
|
const canRecipientBeModified = (recipientId?: number) => {
|
||||||
@ -482,46 +482,30 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
|
|
||||||
const { data } = validatedFormValues;
|
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 hasSigningOrderChanged = envelope.documentMeta.signingOrder !== data.signingOrder;
|
||||||
const hasAllowDictateNextSignerChanged =
|
const hasAllowDictateNextSignerChanged =
|
||||||
envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner;
|
envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner;
|
||||||
|
|
||||||
const hasSignersChanged =
|
const hasSignersChanged =
|
||||||
envelopeRecipients.length !== recipients.length ||
|
data.signers.length !== recipients.length ||
|
||||||
envelopeRecipients.some((signer) => {
|
data.signers.some((signer) => {
|
||||||
const recipient = recipients.find((recipient) => recipient.id === signer.id);
|
const recipient = recipients.find((recipient) => recipient.id === signer.id);
|
||||||
|
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const signerActionAuth = signer.actionAuth;
|
|
||||||
const recipientActionAuth = recipient.authOptions?.actionAuth || [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
signer.email !== recipient.email ||
|
signer.email !== recipient.email ||
|
||||||
signer.name !== recipient.name ||
|
signer.name !== recipient.name ||
|
||||||
signer.role !== recipient.role ||
|
signer.role !== recipient.role ||
|
||||||
signer.signingOrder !== recipient.signingOrder ||
|
signer.signingOrder !== recipient.signingOrder ||
|
||||||
!isDeepEqual(signerActionAuth, recipientActionAuth)
|
!isDeepEqual(signer.actionAuth, recipient.authOptions?.actionAuth)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasSignersChanged) {
|
if (hasSignersChanged) {
|
||||||
setRecipientsDebounced(envelopeRecipients);
|
setRecipientsDebounced(validatedFormValues.data.signers);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) {
|
if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) {
|
||||||
|
|||||||
@ -174,7 +174,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor();
|
const { envelope } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
@ -186,12 +186,14 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
documentAuth: envelope.authOptions,
|
documentAuth: envelope.authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createDefaultValues = () => {
|
const form = useForm<TAddSettingsFormSchema>({
|
||||||
return {
|
resolver: zodResolver(ZAddSettingsFormSchema),
|
||||||
externalId: envelope.externalId || '',
|
defaultValues: {
|
||||||
|
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 ?? '',
|
||||||
@ -208,14 +210,11 @@ 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();
|
||||||
|
|
||||||
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,6 +229,7 @@ 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) => {
|
||||||
@ -240,7 +240,9 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
.safeParse(data.globalAccessAuth);
|
.safeParse(data.globalAccessAuth);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateEnvelopeAsync({
|
await updateEnvelope({
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
envelopeType: envelope.type,
|
||||||
data: {
|
data: {
|
||||||
externalId: data.externalId || null,
|
externalId: data.externalId || null,
|
||||||
visibility: data.visibility,
|
visibility: data.visibility,
|
||||||
@ -295,7 +297,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset(createDefaultValues());
|
form.reset();
|
||||||
setActiveTab('general');
|
setActiveTab('general');
|
||||||
}, [open, form]);
|
}, [open, form]);
|
||||||
|
|
||||||
@ -321,7 +323,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="bg-accent/20 flex w-80 flex-col border-r">
|
<div className="flex w-80 flex-col border-r bg-gray-50">
|
||||||
<DialogHeader className="p-6 pb-4">
|
<DialogHeader className="p-6 pb-4">
|
||||||
<DialogTitle>Document Settings</DialogTitle>
|
<DialogTitle>Document Settings</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@ -18,9 +18,9 @@ import {
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
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,
|
||||||
@ -49,7 +49,7 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
|
const { envelope, setLocalEnvelope, relativePath } = useCurrentEnvelopeEditor();
|
||||||
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -67,8 +67,8 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
|
|
||||||
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.filter(
|
const createdEnvelopes = data.createdEnvelopeItems.filter(
|
||||||
(item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id),
|
(item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -79,10 +79,10 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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.find((item) => item.id === originalItem.id);
|
const updatedItem = data.updatedEnvelopeItems.find((item) => item.id === originalItem.id);
|
||||||
|
|
||||||
if (updatedItem) {
|
if (updatedItem) {
|
||||||
return {
|
return {
|
||||||
@ -114,19 +114,36 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
|
|
||||||
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
|
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
|
||||||
|
|
||||||
const payload = {
|
const result = await Promise.all(
|
||||||
envelopeId: envelope.id,
|
files.map(async (file, index) => {
|
||||||
} satisfies TCreateEnvelopeItemsPayload;
|
try {
|
||||||
|
const response = await putPdfFile(file);
|
||||||
|
|
||||||
const formData = new FormData();
|
// Mark as uploaded (remove from uploading state)
|
||||||
|
return {
|
||||||
formData.append('payload', JSON.stringify(payload));
|
title: file.name,
|
||||||
|
documentDataId: response.id,
|
||||||
for (const file of files) {
|
};
|
||||||
formData.append('files', file);
|
} catch (_error) {
|
||||||
|
setLocalFiles((prev) =>
|
||||||
|
prev.map((uploadingFile) =>
|
||||||
|
uploadingFile.id === newUploadingFiles[index].id
|
||||||
|
? { ...uploadingFile, isError: true, isUploading: false }
|
||||||
|
: uploadingFile,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const { data } = await createEnvelopeItems(formData).catch((error) => {
|
const envelopeItemsToCreate = result.filter(
|
||||||
|
(item): item is { title: string; documentDataId: string } => item !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { createdEnvelopeItems } = await createEnvelopeItems({
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
items: envelopeItemsToCreate,
|
||||||
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
// Set error state on files in batch upload.
|
// Set error state on files in batch upload.
|
||||||
@ -148,7 +165,7 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return filteredFiles.concat(
|
return filteredFiles.concat(
|
||||||
data.map((item) => ({
|
createdEnvelopeItems.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
envelopeItemId: item.id,
|
envelopeItemId: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
@ -165,17 +182,9 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
const onFileDelete = (envelopeItemId: string) => {
|
const onFileDelete = (envelopeItemId: string) => {
|
||||||
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
|
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
|
||||||
|
|
||||||
const fieldsWithoutDeletedItem = envelope.fields.filter(
|
|
||||||
(field) => field.envelopeItemId !== envelopeItemId,
|
|
||||||
);
|
|
||||||
|
|
||||||
setLocalEnvelope({
|
setLocalEnvelope({
|
||||||
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
|
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
|
||||||
fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset editor fields.
|
|
||||||
editorFields.resetForm(fieldsWithoutDeletedItem);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -194,6 +203,7 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
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,
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export const EnvelopeItemSelector = ({
|
|||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-medium ${
|
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium ${
|
||||||
isSelected ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-600'
|
isSelected ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
|
|
||||||
import type Konva from 'konva';
|
import type Konva from 'konva';
|
||||||
|
|
||||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||||
@ -9,24 +8,11 @@ import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/e
|
|||||||
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 { 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 { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
const {
|
const { currentEnvelopeItem, fields, getRecipientColorKey } = useCurrentEnvelopeRender();
|
||||||
envelopeStatus,
|
|
||||||
currentEnvelopeItem,
|
|
||||||
fields,
|
|
||||||
recipients,
|
|
||||||
getRecipientColorKey,
|
|
||||||
setRenderError,
|
|
||||||
overrideSettings,
|
|
||||||
} = useCurrentEnvelopeRender();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
stage,
|
stage,
|
||||||
@ -42,81 +28,44 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
|
|
||||||
const { _className, scale } = pageContext;
|
const { _className, scale } = pageContext;
|
||||||
|
|
||||||
const localPageFields = useMemo((): GenericLocalField[] => {
|
const localPageFields = useMemo(
|
||||||
if (envelopeStatus === DocumentStatus.COMPLETED) {
|
() =>
|
||||||
return [];
|
fields.filter(
|
||||||
}
|
|
||||||
|
|
||||||
return fields
|
|
||||||
.filter(
|
|
||||||
(field) =>
|
(field) =>
|
||||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||||
)
|
),
|
||||||
.map((field) => {
|
[fields, pageContext.pageNumber],
|
||||||
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
|
|
||||||
|
|
||||||
if (!recipient) {
|
|
||||||
throw new Error(`Recipient not found for field ${field.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...field,
|
|
||||||
inserted: isInserted,
|
|
||||||
customText: isInserted ? field.customText : '',
|
|
||||||
recipient,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(
|
|
||||||
({ inserted, fieldMeta, recipient }) =>
|
|
||||||
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
|
|
||||||
fieldMeta?.readOnly,
|
|
||||||
);
|
);
|
||||||
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
|
|
||||||
|
|
||||||
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
|
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 fieldTranslations = getClientSideFieldTranslations(i18n);
|
|
||||||
|
|
||||||
renderField({
|
renderField({
|
||||||
scale,
|
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,
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
signature: {
|
|
||||||
signatureImageAsBase64: '',
|
|
||||||
typedSignature: fieldTranslations.SIGNATURE,
|
|
||||||
},
|
},
|
||||||
},
|
translations: getClientSideFieldTranslations(i18n),
|
||||||
translations: fieldTranslations,
|
|
||||||
pageWidth: unscaledViewport.width,
|
pageWidth: unscaledViewport.width,
|
||||||
pageHeight: unscaledViewport.height,
|
pageHeight: unscaledViewport.height,
|
||||||
color: getRecipientColorKey(field.recipientId),
|
color: getRecipientColorKey(field.recipientId),
|
||||||
editable: false,
|
editable: false,
|
||||||
mode: overrideSettings?.mode ?? 'edit',
|
mode: 'sign',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFieldOnLayer = (field: GenericLocalField) => {
|
|
||||||
try {
|
|
||||||
unsafeRenderFieldOnLayer(field);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setRenderError(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Konva page canvas and all fields and interactions.
|
* Initialize the Konva page canvas and all fields and interactions.
|
||||||
*/
|
*/
|
||||||
@ -164,16 +113,6 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
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. */}
|
{/* 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>
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +8,6 @@ import { Label } from '@documenso/ui/primitives/label';
|
|||||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
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() {
|
||||||
@ -27,8 +25,6 @@ export default function EnvelopeSignerForm() {
|
|||||||
setSelectedAssistantRecipientId,
|
setSelectedAssistantRecipientId,
|
||||||
} = useRequiredEnvelopeSigningContext();
|
} = 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);
|
||||||
}, [recipientFields]);
|
}, [recipientFields]);
|
||||||
@ -41,7 +37,7 @@ export default function EnvelopeSignerForm() {
|
|||||||
|
|
||||||
if (recipient.role === RecipientRole.ASSISTANT) {
|
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||||
return (
|
return (
|
||||||
<fieldset className="embed--DocumentWidgetForm dark:bg-background border-border rounded-2xl sm:border sm:p-3">
|
<fieldset className="dark:bg-background border-border rounded-2xl sm:border sm:p-3">
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
className="gap-0 space-y-2 shadow-none sm:space-y-3"
|
className="gap-0 space-y-2 shadow-none sm:space-y-3"
|
||||||
value={selectedAssistantRecipient?.id?.toString()}
|
value={selectedAssistantRecipient?.id?.toString()}
|
||||||
@ -105,8 +101,7 @@ export default function EnvelopeSignerForm() {
|
|||||||
id="full-name"
|
id="full-name"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
value={fullName}
|
value={fullName}
|
||||||
disabled={isNameLocked}
|
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||||
onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import {
|
|||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
|
|
||||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
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 { BrandingLogoIcon } from '../branding-logo-icon';
|
import { BrandingLogoIcon } from '../branding-logo-icon';
|
||||||
@ -29,7 +28,7 @@ export const EnvelopeSignerHeader = () => {
|
|||||||
useRequiredEnvelopeSigningContext();
|
useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
<nav className="bg-background border-border max-w-screen flex flex-row justify-between border-b px-4 py-3 md:px-6">
|
||||||
{/* Left side - Logo and title */}
|
{/* Left side - Logo and title */}
|
||||||
<div className="flex min-w-0 flex-1 items-center space-x-2 md:w-auto md:flex-none">
|
<div className="flex min-w-0 flex-1 items-center space-x-2 md:w-auto md:flex-none">
|
||||||
<Link to="/" className="flex-shrink-0">
|
<Link to="/" className="flex-shrink-0">
|
||||||
@ -73,7 +72,7 @@ export const EnvelopeSignerHeader = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Desktop content */}
|
{/* Right side - Desktop content */}
|
||||||
<div className="hidden items-center space-x-2 lg:flex">
|
<div className="hidden items-center space-x-2 md:flex">
|
||||||
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
|
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
|
||||||
<Plural
|
<Plural
|
||||||
one="1 Field Remaining"
|
one="1 Field Remaining"
|
||||||
@ -86,7 +85,7 @@ export const EnvelopeSignerHeader = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Actions button */}
|
{/* Mobile Actions button */}
|
||||||
<div className="flex-shrink-0 lg:hidden">
|
<div className="flex-shrink-0 md:hidden">
|
||||||
<MobileDropdownMenu />
|
<MobileDropdownMenu />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@ -96,8 +95,6 @@ export const EnvelopeSignerHeader = () => {
|
|||||||
const MobileDropdownMenu = () => {
|
const MobileDropdownMenu = () => {
|
||||||
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
|
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
const { allowDocumentRejection } = useEmbedSigningContext() || {};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@ -122,7 +119,7 @@ const MobileDropdownMenu = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection !== false && (
|
{envelope.type === EnvelopeType.DOCUMENT && (
|
||||||
<DocumentSigningRejectDialog
|
<DocumentSigningRejectDialog
|
||||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
token={recipient.token}
|
token={recipient.token}
|
||||||
|
|||||||
@ -1,14 +1,7 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import {
|
import { type Field, FieldType, RecipientRole, type Signature } from '@prisma/client';
|
||||||
type Field,
|
|
||||||
FieldType,
|
|
||||||
type Recipient,
|
|
||||||
RecipientRole,
|
|
||||||
type Signature,
|
|
||||||
SigningStatus,
|
|
||||||
} from '@prisma/client';
|
|
||||||
import type Konva from 'konva';
|
import type Konva from 'konva';
|
||||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@ -17,22 +10,15 @@ import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-rende
|
|||||||
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 { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
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 type { TEnvelope } from '@documenso/lib/types/envelope';
|
|
||||||
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 { 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 { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
|
|
||||||
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
|
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 { 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';
|
||||||
@ -42,28 +28,20 @@ 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';
|
||||||
|
|
||||||
type GenericLocalField = TEnvelope['fields'][number] & {
|
|
||||||
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function EnvelopeSignerPageRenderer() {
|
export default function EnvelopeSignerPageRenderer() {
|
||||||
const { t, i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
const { sessionData } = useOptionalSession();
|
const { sessionData } = useOptionalSession();
|
||||||
|
|
||||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
envelopeData,
|
envelopeData,
|
||||||
recipient,
|
recipient,
|
||||||
recipientFields,
|
recipientFields,
|
||||||
recipientFieldsRemaining,
|
recipientFieldsRemaining,
|
||||||
showPendingFieldTooltip,
|
showPendingFieldTooltip,
|
||||||
signField: signFieldInternal,
|
signField,
|
||||||
email,
|
email,
|
||||||
setEmail,
|
setEmail,
|
||||||
fullName,
|
fullName,
|
||||||
@ -75,8 +53,6 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
isDirectTemplate,
|
isDirectTemplate,
|
||||||
} = useRequiredEnvelopeSigningContext();
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
stage,
|
stage,
|
||||||
pageLayer,
|
pageLayer,
|
||||||
@ -104,37 +80,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
);
|
);
|
||||||
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
|
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
|
||||||
|
|
||||||
/**
|
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||||
* Returns fields that have been fully signed by other recipients for this specific
|
|
||||||
* page.
|
|
||||||
*/
|
|
||||||
const localPageOtherRecipientFields = useMemo((): GenericLocalField[] => {
|
|
||||||
const signedRecipients = envelope.recipients.filter(
|
|
||||||
(recipient) => recipient.signingStatus === SigningStatus.SIGNED,
|
|
||||||
);
|
|
||||||
|
|
||||||
return signedRecipients.flatMap((recipient) => {
|
|
||||||
return recipient.fields
|
|
||||||
.filter(
|
|
||||||
(field) =>
|
|
||||||
field.page === pageContext.pageNumber &&
|
|
||||||
field.envelopeItemId === currentEnvelopeItem?.id &&
|
|
||||||
(field.inserted || field.fieldMeta?.readOnly),
|
|
||||||
)
|
|
||||||
.map((field) => ({
|
|
||||||
...field,
|
|
||||||
recipient: {
|
|
||||||
id: recipient.id,
|
|
||||||
name: recipient.name,
|
|
||||||
email: recipient.email,
|
|
||||||
signingStatus: recipient.signingStatus,
|
|
||||||
role: recipient.role,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}, [envelope.recipients, pageContext.pageNumber]);
|
|
||||||
|
|
||||||
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
console.error('Layer not loaded yet');
|
console.error('Layer not loaded yet');
|
||||||
return;
|
return;
|
||||||
@ -291,7 +237,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
.then(async (payload) => {
|
.then(async (payload) => {
|
||||||
if (payload) {
|
if (payload) {
|
||||||
fieldGroup.add(loadingSpinnerGroup);
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
await signField(field.id, payload);
|
await signField(field.id, payload); // Todo: Envelopes - Handle errors
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload?.value) {
|
if (payload?.value) {
|
||||||
@ -372,6 +318,7 @@ 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,
|
||||||
@ -382,21 +329,11 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
.then(async (payload) => {
|
.then(async (payload) => {
|
||||||
if (payload) {
|
if (payload) {
|
||||||
fieldGroup.add(loadingSpinnerGroup);
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
|
|
||||||
if (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);
|
await signField(field.id, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload?.value) {
|
||||||
|
setSignature(payload.value);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@ -410,92 +347,15 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
fieldGroup.on('pointerdown', handleFieldGroupClick);
|
fieldGroup.on('pointerdown', handleFieldGroupClick);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
|
||||||
try {
|
|
||||||
unsafeRenderFieldOnLayer(unparsedField);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setRenderError(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderFields = () => {
|
|
||||||
if (!pageLayer.current) {
|
|
||||||
console.error('Layer not loaded yet');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render current recipient fields.
|
|
||||||
for (const field of localPageFields) {
|
|
||||||
renderFieldOnLayer(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render other recipient signed and inserted fields.
|
|
||||||
for (const field of localPageOtherRecipientFields) {
|
|
||||||
try {
|
|
||||||
renderField({
|
|
||||||
scale,
|
|
||||||
pageLayer: pageLayer.current,
|
|
||||||
field: {
|
|
||||||
renderId: field.id.toString(),
|
|
||||||
...field,
|
|
||||||
width: Number(field.width),
|
|
||||||
height: Number(field.height),
|
|
||||||
positionX: Number(field.positionX),
|
|
||||||
positionY: Number(field.positionY),
|
|
||||||
fieldMeta: field.fieldMeta,
|
|
||||||
},
|
|
||||||
translations: getClientSideFieldTranslations(i18n),
|
|
||||||
pageWidth: unscaledViewport.width,
|
|
||||||
pageHeight: unscaledViewport.height,
|
|
||||||
color: 'readOnly',
|
|
||||||
editable: false,
|
|
||||||
mode: 'sign',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Unable to render one or more fields belonging to other recipients.');
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Konva page canvas and all fields and interactions.
|
* Initialize the Konva page canvas and all fields and interactions.
|
||||||
*/
|
*/
|
||||||
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||||
renderFields();
|
// Render the fields.
|
||||||
|
for (const field of localPageFields) {
|
||||||
|
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
||||||
|
}
|
||||||
|
|
||||||
currentPageLayer.batchDraw();
|
currentPageLayer.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -507,7 +367,10 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderFields();
|
localPageFields.forEach((field) => {
|
||||||
|
console.log('Field changed/inserted, rendering on canvas');
|
||||||
|
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
||||||
|
});
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
|
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
|
||||||
@ -523,7 +386,9 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
// Rerender the whole page.
|
// Rerender the whole page.
|
||||||
pageLayer.current.destroyChildren();
|
pageLayer.current.destroyChildren();
|
||||||
|
|
||||||
renderFields();
|
localPageFields.forEach((field) => {
|
||||||
|
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
||||||
|
});
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
}, [selectedAssistantRecipient]);
|
}, [selectedAssistantRecipient]);
|
||||||
@ -550,15 +415,6 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
</EnvelopeFieldToolTip>
|
</EnvelopeFieldToolTip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{localPageOtherRecipientFields.map((field) => (
|
|
||||||
<EnvelopeRecipientFieldTooltip
|
|
||||||
key={field.id}
|
|
||||||
field={field}
|
|
||||||
showFieldStatus={true}
|
|
||||||
showRecipientTooltip={true}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* The element Konva will inject it's canvas into. */}
|
{/* 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>
|
||||||
|
|
||||||
|
|||||||
@ -2,19 +2,16 @@ import { useMemo } from 'react';
|
|||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { FieldType } from '@prisma/client';
|
import { FieldType } from '@prisma/client';
|
||||||
import { useNavigate, useRevalidator, useSearchParams } from 'react-router';
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
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 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 { trpc } from '@documenso/trpc/react';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
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 { DocumentSigningCompleteDialog } from '../document-signing/document-signing-complete-dialog';
|
||||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||||
|
|
||||||
@ -22,9 +19,8 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { revalidate } = useRevalidator();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
@ -41,8 +37,6 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
|
|
||||||
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
|
|
||||||
|
|
||||||
const { mutateAsync: completeDocument, isPending } =
|
const { mutateAsync: completeDocument, isPending } =
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
@ -74,13 +68,14 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
nextSigner?: { name: string; email: string },
|
nextSigner?: { name: string; email: string },
|
||||||
accessAuthOptions?: TRecipientAccessAuth,
|
accessAuthOptions?: TRecipientAccessAuth,
|
||||||
) => {
|
) => {
|
||||||
try {
|
const payload = {
|
||||||
await completeDocument({
|
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
accessAuthOptions,
|
authOptions: accessAuthOptions,
|
||||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
await completeDocument(payload);
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
signerId: recipient.id,
|
signerId: recipient.id,
|
||||||
@ -88,39 +83,11 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
timestamp: new Date().toISOString(),
|
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) {
|
if (envelope.documentMeta.redirectUrl) {
|
||||||
window.location.href = envelope.documentMeta.redirectUrl;
|
window.location.href = envelope.documentMeta.redirectUrl;
|
||||||
} else {
|
} else {
|
||||||
await navigate(`/sign/${recipient.token}/complete`);
|
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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -138,12 +105,8 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!recipient.directToken) {
|
|
||||||
throw new Error('Recipient direct token is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { token } = await createDocumentFromDirectTemplate({
|
const { token } = await createDocumentFromDirectTemplate({
|
||||||
directTemplateToken: recipient.directToken, // The direct template token is inserted into the recipient token for ease of use.
|
directTemplateToken: recipient.token, // The direct template token is inserted into the recipient token for ease of use.
|
||||||
directTemplateExternalId,
|
directTemplateExternalId,
|
||||||
directRecipientName: recipientDetails?.name || fullName,
|
directRecipientName: recipientDetails?.name || fullName,
|
||||||
directRecipientEmail: recipientDetails?.email || email,
|
directRecipientEmail: recipientDetails?.email || email,
|
||||||
@ -169,31 +132,18 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
|
|
||||||
const redirectUrl = envelope.documentMeta.redirectUrl;
|
const redirectUrl = envelope.documentMeta.redirectUrl;
|
||||||
|
|
||||||
if (onDocumentCompleted) {
|
|
||||||
await navigate({
|
|
||||||
pathname: `/embed/sign/${token}`,
|
|
||||||
search: window.location.search,
|
|
||||||
hash: window.location.hash,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (redirectUrl) {
|
if (redirectUrl) {
|
||||||
window.location.href = redirectUrl;
|
window.location.href = redirectUrl;
|
||||||
} else {
|
} else {
|
||||||
await navigate(`/sign/${token}/complete`);
|
await navigate(`/sign/${token}/complete`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('err', err);
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Something went wrong`,
|
title: t`Something went wrong`,
|
||||||
description: t`We were unable to submit this document at this time. Please try again later.`,
|
description: t`We were unable to submit this document at this time. Please try again later.`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
onDocumentError?.();
|
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,6 +6,7 @@ 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 { 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';
|
||||||
@ -16,11 +17,11 @@ import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
|||||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||||
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
|
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
|
||||||
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
|
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
|
||||||
import { DocumentUploadButtonLegacy } from '~/components/general/document/document-upload-button-legacy';
|
import { DocumentUploadButton } from '~/components/general/document/document-upload-button';
|
||||||
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 '../envelope/envelope-upload-button';
|
import { EnvelopeUploadButton } from '../document/envelope-upload-button';
|
||||||
|
|
||||||
export type FolderGridProps = {
|
export type FolderGridProps = {
|
||||||
type: FolderType;
|
type: FolderType;
|
||||||
@ -98,12 +99,14 @@ 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">
|
||||||
|
{(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 ? (
|
||||||
<DocumentUploadButtonLegacy /> // If you delete this, delete the component as well.
|
<DocumentUploadButton />
|
||||||
) : (
|
) : (
|
||||||
<TemplateCreateDialog folderId={parentId ?? undefined} /> // If you delete this, delete the component as well.
|
<TemplateCreateDialog folderId={parentId ?? undefined} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FolderCreateDialog type={type} />
|
<FolderCreateDialog type={type} />
|
||||||
|
|||||||
@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,167 @@
|
|||||||
|
import { type ReactNode, useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
||||||
|
import { useNavigate, useParams } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
|
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 { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export interface TemplateDropZoneWrapperProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZoneWrapperProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { folderId } = useParams();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||||
|
|
||||||
|
const onFileDrop = async (file: File) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const documentData = await putPdfFile(file);
|
||||||
|
|
||||||
|
const { legacyTemplateId: id } = await createTemplate({
|
||||||
|
title: file.name,
|
||||||
|
templateDocumentDataId: documentData.id,
|
||||||
|
folderId: folderId ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Template uploaded`),
|
||||||
|
description: _(
|
||||||
|
msg`Your template has been uploaded successfully. You will be redirected to the template page.`,
|
||||||
|
),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(msg`Please try again later.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
||||||
|
if (!fileRejections.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
||||||
|
const { file, errors } = fileRejections[0];
|
||||||
|
|
||||||
|
if (!errors.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorNodes = errors.map((error, index) => (
|
||||||
|
<span key={index} className="block">
|
||||||
|
{match(error.code)
|
||||||
|
.with(ErrorCode.FileTooLarge, () => (
|
||||||
|
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
|
||||||
|
))
|
||||||
|
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
|
||||||
|
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
|
||||||
|
.with(ErrorCode.TooManyFiles, () => (
|
||||||
|
<Trans>Only one file can be uploaded at a time</Trans>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<Trans>Unknown error</Trans>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
|
||||||
|
const description = (
|
||||||
|
<>
|
||||||
|
<span className="font-medium">
|
||||||
|
{file.name} <Trans>couldn't be uploaded:</Trans>
|
||||||
|
</span>
|
||||||
|
{errorNodes}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Upload failed`),
|
||||||
|
description,
|
||||||
|
duration: 5000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
accept: {
|
||||||
|
'application/pdf': ['.pdf'],
|
||||||
|
},
|
||||||
|
//disabled: isUploadDisabled,
|
||||||
|
multiple: false,
|
||||||
|
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||||
|
onDrop: ([acceptedFile]) => {
|
||||||
|
if (acceptedFile) {
|
||||||
|
void onFileDrop(acceptedFile);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDropRejected: (fileRejections) => {
|
||||||
|
onFileDropRejected(fileRejections);
|
||||||
|
},
|
||||||
|
noClick: true,
|
||||||
|
noDragEventsBubbling: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...getRootProps()} className={cn('relative min-h-screen', className)}>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{isDragActive && (
|
||||||
|
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
||||||
|
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
||||||
|
<h2 className="text-foreground text-2xl font-semibold">
|
||||||
|
<Trans>Upload Template</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-md mt-4">
|
||||||
|
<Trans>Drag and drop your PDF file here</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
|
||||||
|
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
||||||
|
<Loader className="text-primary h-12 w-12 animate-spin" />
|
||||||
|
<p className="text-foreground mt-8 font-medium">
|
||||||
|
<Trans>Uploading template...</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -313,10 +313,8 @@ export const TemplateEditForm = ({
|
|||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
key={template.envelopeItems[0].id}
|
key={templateDocumentData.id}
|
||||||
envelopeItem={template.envelopeItems[0]}
|
documentData={templateDocumentData}
|
||||||
token={undefined}
|
|
||||||
version="signed"
|
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -7,13 +7,11 @@ import type { User } from '@prisma/client';
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
|
||||||
|
|
||||||
export type TemplatePageViewInformationProps = {
|
export type TemplatePageViewInformationProps = {
|
||||||
userId: number;
|
userId: number;
|
||||||
template: {
|
template: {
|
||||||
userId: number;
|
userId: number;
|
||||||
secondaryId: string;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
@ -45,10 +43,6 @@ export const TemplatePageViewInformation = ({
|
|||||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
.toRelative(),
|
.toRelative(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
description: msg`Template ID (Legacy)`,
|
|
||||||
value: mapSecondaryIdToTemplateId(template.secondaryId),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isMounted, template, userId]);
|
}, [isMounted, template, userId]);
|
||||||
|
|||||||
@ -2,49 +2,40 @@ import { useEffect, useMemo, useState, useTransition } 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 { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react';
|
import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
|
||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
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 type { DateRange } from '@documenso/lib/types/search-params';
|
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
export type OrganisationOverview = {
|
export type SigningVolume = {
|
||||||
id: string;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
signingVolume: number;
|
signingVolume: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
customerId: string;
|
planId: string;
|
||||||
subscriptionStatus?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
teamCount?: number;
|
|
||||||
memberCount?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type OrganisationOverviewTableProps = {
|
type LeaderboardTableProps = {
|
||||||
organisations: OrganisationOverview[];
|
signingVolume: SigningVolume[];
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
perPage: number;
|
perPage: number;
|
||||||
page: number;
|
page: number;
|
||||||
sortBy: 'name' | 'createdAt' | 'signingVolume';
|
sortBy: 'name' | 'createdAt' | 'signingVolume';
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc';
|
||||||
dateRange: DateRange;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminOrganisationOverviewTable = ({
|
export const AdminLeaderboardTable = ({
|
||||||
organisations,
|
signingVolume,
|
||||||
totalPages,
|
totalPages,
|
||||||
perPage,
|
perPage,
|
||||||
page,
|
page,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
dateRange,
|
}: LeaderboardTableProps) => {
|
||||||
}: OrganisationOverviewTableProps) => {
|
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
@ -76,16 +67,17 @@ export const AdminOrganisationOverviewTable = ({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<a
|
||||||
className="hover:underline"
|
className="text-primary underline"
|
||||||
to={`/admin/organisation-insights/${row.original.id}?dateRange=${dateRange}`}
|
href={`https://dashboard.stripe.com/subscriptions/${row.original.planId}`}
|
||||||
|
target="_blank"
|
||||||
>
|
>
|
||||||
{row.getValue('name')}
|
{row.getValue('name')}
|
||||||
</Link>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
size: 240,
|
size: 250,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: () => (
|
header: () => (
|
||||||
@ -93,9 +85,7 @@ export const AdminOrganisationOverviewTable = ({
|
|||||||
className="flex cursor-pointer items-center"
|
className="flex cursor-pointer items-center"
|
||||||
onClick={() => handleColumnSort('signingVolume')}
|
onClick={() => handleColumnSort('signingVolume')}
|
||||||
>
|
>
|
||||||
<span className="whitespace-nowrap">
|
{_(msg`Signing Volume`)}
|
||||||
<Trans>Document Volume</Trans>
|
|
||||||
</span>
|
|
||||||
{sortBy === 'signingVolume' ? (
|
{sortBy === 'signingVolume' ? (
|
||||||
sortOrder === 'asc' ? (
|
sortOrder === 'asc' ? (
|
||||||
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
||||||
@ -109,23 +99,6 @@ export const AdminOrganisationOverviewTable = ({
|
|||||||
),
|
),
|
||||||
accessorKey: 'signingVolume',
|
accessorKey: 'signingVolume',
|
||||||
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
|
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
|
||||||
size: 160,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: () => {
|
|
||||||
return <Trans>Teams</Trans>;
|
|
||||||
},
|
|
||||||
accessorKey: 'teamCount',
|
|
||||||
cell: ({ row }) => <div>{Number(row.original.teamCount) || 0}</div>,
|
|
||||||
size: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: () => {
|
|
||||||
return <Trans>Members</Trans>;
|
|
||||||
},
|
|
||||||
accessorKey: 'memberCount',
|
|
||||||
cell: ({ row }) => <div>{Number(row.original.memberCount) || 0}</div>,
|
|
||||||
size: 160,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: () => {
|
header: () => {
|
||||||
@ -134,9 +107,7 @@ export const AdminOrganisationOverviewTable = ({
|
|||||||
className="flex cursor-pointer items-center"
|
className="flex cursor-pointer items-center"
|
||||||
onClick={() => handleColumnSort('createdAt')}
|
onClick={() => handleColumnSort('createdAt')}
|
||||||
>
|
>
|
||||||
<span className="whitespace-nowrap">
|
{_(msg`Created`)}
|
||||||
<Trans>Created</Trans>
|
|
||||||
</span>
|
|
||||||
{sortBy === 'createdAt' ? (
|
{sortBy === 'createdAt' ? (
|
||||||
sortOrder === 'asc' ? (
|
sortOrder === 'asc' ? (
|
||||||
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
||||||
@ -150,11 +121,10 @@ export const AdminOrganisationOverviewTable = ({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => i18n.date(new Date(row.original.createdAt)),
|
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||||
size: 120,
|
|
||||||
},
|
},
|
||||||
] satisfies DataTableColumnDef<OrganisationOverview>[];
|
] satisfies DataTableColumnDef<SigningVolume>[];
|
||||||
}, [sortOrder, sortBy, dateRange]);
|
}, [sortOrder, sortBy]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
@ -199,13 +169,13 @@ export const AdminOrganisationOverviewTable = ({
|
|||||||
<Input
|
<Input
|
||||||
className="my-6 flex flex-row gap-4"
|
className="my-6 flex flex-row gap-4"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={_(msg`Search by organisation name`)}
|
placeholder={_(msg`Search by name or email`)}
|
||||||
value={searchString}
|
value={searchString}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={organisations}
|
data={signingVolume}
|
||||||
perPage={perPage}
|
perPage={perPage}
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
@ -93,31 +93,13 @@ export const AdminOrganisationsTable = ({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'role',
|
header: t`Status`,
|
||||||
header: t`Role`,
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge variant="neutral">
|
<Badge variant="neutral">
|
||||||
{row.original.owner.id === memberUserId ? t`Owner` : t`Member`}
|
{row.original.owner.id === memberUserId ? t`Owner` : t`Member`}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'billingStatus',
|
|
||||||
header: t`Status`,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const subscription = row.original.subscription;
|
|
||||||
const isPaid = subscription && subscription.status === 'ACTIVE';
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
|
|
||||||
isPaid ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isPaid ? t`Paid` : t`Free`}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
header: t`Subscription`,
|
header: t`Subscription`,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
@ -186,7 +168,7 @@ export const AdminOrganisationsTable = ({
|
|||||||
onPaginationChange={onPaginationChange}
|
onPaginationChange={onPaginationChange}
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
owner: showOwnerColumn,
|
owner: showOwnerColumn,
|
||||||
role: memberUserId !== undefined,
|
status: memberUserId !== undefined,
|
||||||
}}
|
}}
|
||||||
error={{
|
error={{
|
||||||
enable: isLoadingError,
|
enable: isLoadingError,
|
||||||
|
|||||||
@ -1,14 +1,19 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
@ -20,6 +25,8 @@ export type DocumentsTableActionButtonProps = {
|
|||||||
|
|
||||||
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();
|
||||||
|
|
||||||
@ -37,6 +44,39 @@ 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;
|
||||||
@ -94,7 +134,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
<Trans>View</Trans>
|
<Trans>View</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true, internalVersion: 2 }, () => (
|
||||||
<EnvelopeDownloadDialog
|
<EnvelopeDownloadDialog
|
||||||
envelopeId={row.envelopeId}
|
envelopeId={row.envelopeId}
|
||||||
envelopeStatus={row.status}
|
envelopeStatus={row.status}
|
||||||
@ -107,5 +147,11 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
.with({ isComplete: true }, () => (
|
||||||
|
<Button className="w-32" onClick={onDownloadClick}>
|
||||||
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
.otherwise(() => <div></div>);
|
.otherwise(() => <div></div>);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
|
FileDown,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
Loader,
|
Loader,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
@ -19,10 +20,12 @@ 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,
|
||||||
@ -31,6 +34,7 @@ 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';
|
||||||
@ -52,6 +56,7 @@ 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);
|
||||||
@ -71,6 +76,58 @@ 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 (
|
||||||
@ -121,6 +178,7 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{row.internalVersion === 2 ? (
|
||||||
<EnvelopeDownloadDialog
|
<EnvelopeDownloadDialog
|
||||||
envelopeId={row.envelopeId}
|
envelopeId={row.envelopeId}
|
||||||
envelopeStatus={row.status}
|
envelopeStatus={row.status}
|
||||||
@ -134,6 +192,19 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||||
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Download Original</Trans>
|
||||||
|
</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" />
|
||||||
@ -202,8 +273,7 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DocumentDuplicateDialog
|
<DocumentDuplicateDialog
|
||||||
id={row.envelopeId}
|
id={row.id}
|
||||||
token={recipient?.token}
|
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -10,9 +10,11 @@ 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';
|
||||||
@ -26,7 +28,6 @@ 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 = {
|
||||||
@ -198,6 +199,28 @@ 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;
|
||||||
@ -207,7 +230,6 @@ 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>
|
||||||
@ -241,17 +263,10 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
|||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<EnvelopeDownloadDialog
|
<Button className="w-32" onClick={onDownloadClick}>
|
||||||
envelopeId={row.envelopeId}
|
|
||||||
envelopeStatus={row.status}
|
|
||||||
token={recipient?.token}
|
|
||||||
trigger={
|
|
||||||
<Button className="w-32">
|
|
||||||
<DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
|
<DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
<Trans>Download</Trans>
|
<Trans>Download</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
.otherwise(() => <div></div>);
|
.otherwise(() => <div></div>);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,287 +0,0 @@
|
|||||||
import { useTransition } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Building2, Loader, TrendingUp, Users } from 'lucide-react';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
import { useNavigation } from 'react-router';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import type { OrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
|
|
||||||
import type { DateRange } from '@documenso/lib/types/search-params';
|
|
||||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
|
|
||||||
import { DateRangeFilter } from '~/components/filters/date-range-filter';
|
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
|
||||||
|
|
||||||
type OrganisationInsightsTableProps = {
|
|
||||||
insights: OrganisationDetailedInsights;
|
|
||||||
page: number;
|
|
||||||
perPage: number;
|
|
||||||
dateRange: DateRange;
|
|
||||||
view: 'teams' | 'users' | 'documents';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OrganisationInsightsTable = ({
|
|
||||||
insights,
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
dateRange,
|
|
||||||
view,
|
|
||||||
}: OrganisationInsightsTableProps) => {
|
|
||||||
const { _, i18n } = useLingui();
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const isLoading = isPending || navigation.state === 'loading';
|
|
||||||
|
|
||||||
const onPaginationChange = (newPage: number, newPerPage: number) => {
|
|
||||||
startTransition(() => {
|
|
||||||
updateSearchParams({
|
|
||||||
page: newPage,
|
|
||||||
perPage: newPerPage,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewChange = (newView: 'teams' | 'users' | 'documents') => {
|
|
||||||
startTransition(() => {
|
|
||||||
updateSearchParams({
|
|
||||||
view: newView,
|
|
||||||
page: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const teamsColumns = [
|
|
||||||
{
|
|
||||||
header: _(msg`Team Name`),
|
|
||||||
accessorKey: 'name',
|
|
||||||
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('name')}</span>,
|
|
||||||
size: 240,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Members`),
|
|
||||||
accessorKey: 'memberCount',
|
|
||||||
cell: ({ row }) => Number(row.getValue('memberCount')),
|
|
||||||
size: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Documents`),
|
|
||||||
accessorKey: 'documentCount',
|
|
||||||
cell: ({ row }) => Number(row.getValue('documentCount')),
|
|
||||||
size: 140,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Created`),
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
|
|
||||||
size: 160,
|
|
||||||
},
|
|
||||||
] satisfies DataTableColumnDef<(typeof insights.teams)[number]>[];
|
|
||||||
|
|
||||||
const usersColumns = [
|
|
||||||
{
|
|
||||||
header: () => <span className="whitespace-nowrap">{_(msg`Name`)}</span>,
|
|
||||||
accessorKey: 'name',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Link
|
|
||||||
className="block max-w-full truncate hover:underline"
|
|
||||||
to={`/admin/users/${row.original.id}`}
|
|
||||||
>
|
|
||||||
{(row.getValue('name') as string) || (row.getValue('email') as string)}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
size: 220,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: () => <span className="whitespace-nowrap">{_(msg`Email`)}</span>,
|
|
||||||
accessorKey: 'email',
|
|
||||||
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('email')}</span>,
|
|
||||||
size: 260,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: () => <span className="whitespace-nowrap">{_(msg`Documents Created`)}</span>,
|
|
||||||
accessorKey: 'documentCount',
|
|
||||||
cell: ({ row }) => Number(row.getValue('documentCount')),
|
|
||||||
size: 180,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: () => <span className="whitespace-nowrap">{_(msg`Documents Completed`)}</span>,
|
|
||||||
accessorKey: 'signedDocumentCount',
|
|
||||||
cell: ({ row }) => Number(row.getValue('signedDocumentCount')),
|
|
||||||
size: 180,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: () => <span className="whitespace-nowrap">{_(msg`Joined`)}</span>,
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
|
|
||||||
size: 160,
|
|
||||||
},
|
|
||||||
] satisfies DataTableColumnDef<(typeof insights.users)[number]>[];
|
|
||||||
|
|
||||||
const documentsColumns = [
|
|
||||||
{
|
|
||||||
header: () => <span className="whitespace-nowrap">{_(msg`Title`)}</span>,
|
|
||||||
accessorKey: 'title',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Link
|
|
||||||
className="block max-w-[200px] truncate hover:underline"
|
|
||||||
to={`/admin/documents/${row.original.id}`}
|
|
||||||
title={row.getValue('title') as string}
|
|
||||||
>
|
|
||||||
{row.getValue('title')}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
size: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: () => <span className="whitespace-nowrap">{_(msg`Status`)}</span>,
|
|
||||||
accessorKey: 'status',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DocumentStatus status={row.getValue('status') as ExtendedDocumentStatus} />
|
|
||||||
),
|
|
||||||
size: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: () => <span className="whitespace-nowrap">{_(msg`Team`)}</span>,
|
|
||||||
accessorKey: 'teamName',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="block max-w-[150px] truncate" title={row.getValue('teamName') as string}>
|
|
||||||
{row.getValue('teamName')}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
size: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: () => <span className="whitespace-nowrap">{_(msg`Created`)}</span>,
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
|
|
||||||
size: 140,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: () => <span className="whitespace-nowrap">{_(msg`Completed`)}</span>,
|
|
||||||
accessorKey: 'completedAt',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const completedAt = row.getValue('completedAt') as Date | null;
|
|
||||||
|
|
||||||
return completedAt ? i18n.date(new Date(completedAt)) : '-';
|
|
||||||
},
|
|
||||||
size: 140,
|
|
||||||
},
|
|
||||||
] satisfies DataTableColumnDef<(typeof insights.documents)[number]>[];
|
|
||||||
|
|
||||||
const getCurrentData = (): unknown[] => {
|
|
||||||
switch (view) {
|
|
||||||
case 'teams':
|
|
||||||
return insights.teams;
|
|
||||||
case 'users':
|
|
||||||
return insights.users;
|
|
||||||
case 'documents':
|
|
||||||
return insights.documents;
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCurrentColumns = (): DataTableColumnDef<unknown>[] => {
|
|
||||||
switch (view) {
|
|
||||||
case 'teams':
|
|
||||||
return teamsColumns as unknown as DataTableColumnDef<unknown>[];
|
|
||||||
case 'users':
|
|
||||||
return usersColumns as unknown as DataTableColumnDef<unknown>[];
|
|
||||||
case 'documents':
|
|
||||||
return documentsColumns as unknown as DataTableColumnDef<unknown>[];
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
{insights.summary && (
|
|
||||||
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-3">
|
|
||||||
<SummaryCard icon={Building2} title={_(msg`Teams`)} value={insights.summary.totalTeams} />
|
|
||||||
<SummaryCard icon={Users} title={_(msg`Members`)} value={insights.summary.totalMembers} />
|
|
||||||
<SummaryCard
|
|
||||||
icon={TrendingUp}
|
|
||||||
title={_(msg`Documents Completed`)}
|
|
||||||
value={insights.summary.volumeThisPeriod}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant={view === 'teams' ? 'default' : 'outline'}
|
|
||||||
onClick={() => handleViewChange('teams')}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{_(msg`Teams`)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={view === 'users' ? 'default' : 'outline'}
|
|
||||||
onClick={() => handleViewChange('users')}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{_(msg`Users`)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={view === 'documents' ? 'default' : 'outline'}
|
|
||||||
onClick={() => handleViewChange('documents')}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{_(msg`Documents`)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DateRangeFilter currentRange={dateRange} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={view === 'documents' ? 'overflow-hidden' : undefined}>
|
|
||||||
<DataTable<unknown, unknown>
|
|
||||||
columns={getCurrentColumns()}
|
|
||||||
data={getCurrentData()}
|
|
||||||
perPage={perPage}
|
|
||||||
currentPage={page}
|
|
||||||
totalPages={insights.totalPages}
|
|
||||||
onPaginationChange={onPaginationChange}
|
|
||||||
>
|
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
|
||||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SummaryCard = ({
|
|
||||||
icon: Icon,
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
subtitle,
|
|
||||||
}: {
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
title: string;
|
|
||||||
value: number;
|
|
||||||
subtitle?: string;
|
|
||||||
}) => (
|
|
||||||
<div className="bg-card flex items-start gap-x-2 rounded-lg border px-4 py-3">
|
|
||||||
<Icon className="text-muted-foreground h-4 w-4 items-start" />
|
|
||||||
<div className="-mt-0.5 space-y-2">
|
|
||||||
<p className="text-muted-foreground text-sm font-medium">{title}</p>
|
|
||||||
<p className="text-2xl font-bold">{value}</p>
|
|
||||||
{subtitle && <p className="text-muted-foreground text-xs">{subtitle}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@ -56,14 +56,7 @@ export const UserOrganisationsTable = () => {
|
|||||||
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
||||||
primaryText={
|
primaryText={
|
||||||
<span className="text-foreground/80 font-semibold">
|
<span className="text-foreground/80 font-semibold">
|
||||||
{isPersonalLayoutMode
|
{isPersonalLayoutMode ? _(msg`Personal`) : row.original.name}
|
||||||
? _(
|
|
||||||
msg({
|
|
||||||
message: `Personal`,
|
|
||||||
context: `Personal organisation (adjective)`,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: row.original.name}
|
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
secondaryText={
|
secondaryText={
|
||||||
|
|||||||
@ -88,12 +88,14 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
|
|||||||
? {
|
? {
|
||||||
heading: msg`Organisation not found`,
|
heading: msg`Organisation not found`,
|
||||||
subHeading: msg`404 Organisation not found`,
|
subHeading: msg`404 Organisation not found`,
|
||||||
message: msg`The organisation you are looking for may have been removed, renamed or may have never existed.`,
|
message: msg`The organisation you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
heading: msg`Team not found`,
|
heading: msg`Team not found`,
|
||||||
subHeading: msg`404 Team not found`,
|
subHeading: msg`404 Team not found`,
|
||||||
message: msg`The team you are looking for may have been removed, renamed or may have never existed.`,
|
message: msg`The team you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -114,13 +114,13 @@ export default function AdminLayout() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'justify-start md:w-full',
|
'justify-start md:w-full',
|
||||||
pathname?.startsWith('/admin/organisation-insights') && 'bg-secondary',
|
pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link to="/admin/organisation-insights">
|
<Link to="/admin/leaderboard">
|
||||||
<Trophy className="mr-2 h-5 w-5" />
|
<Trophy className="mr-2 h-5 w-5" />
|
||||||
<Trans>Organisation Insights</Trans>
|
<Trans>Leaderboard</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -128,7 +128,7 @@ export default function AdminLayout() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'justify-start md:w-full',
|
'justify-start md:w-full',
|
||||||
pathname?.startsWith('/admin/site-settings') && 'bg-secondary',
|
pathname?.startsWith('/admin/banner') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
|
|||||||
77
apps/remix/app/routes/_authenticated+/admin+/leaderboard.tsx
Normal file
77
apps/remix/app/routes/_authenticated+/admin+/leaderboard.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdminLeaderboardTable,
|
||||||
|
type SigningVolume,
|
||||||
|
} from '~/components/tables/admin-leaderboard-table';
|
||||||
|
|
||||||
|
import type { Route } from './+types/leaderboard';
|
||||||
|
|
||||||
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
|
||||||
|
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const sortOrder = (['asc', 'desc'].includes(rawSortOrder) ? rawSortOrder : 'desc') as
|
||||||
|
| 'asc'
|
||||||
|
| 'desc';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const sortBy = (
|
||||||
|
['name', 'createdAt', 'signingVolume'].includes(rawSortBy) ? rawSortBy : 'signingVolume'
|
||||||
|
) as 'name' | 'createdAt' | 'signingVolume';
|
||||||
|
|
||||||
|
const page = Number(url.searchParams.get('page')) || 1;
|
||||||
|
const perPage = Number(url.searchParams.get('perPage')) || 10;
|
||||||
|
const search = url.searchParams.get('search') || '';
|
||||||
|
|
||||||
|
const { leaderboard, totalPages } = await getSigningVolume({
|
||||||
|
search,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({
|
||||||
|
...item,
|
||||||
|
name: item.name || '',
|
||||||
|
createdAt: item.createdAt || new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
signingVolume: typedSigningVolume,
|
||||||
|
totalPages,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Leaderboard({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { signingVolume, totalPages, page, perPage, sortBy, sortOrder } = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<Trans>Signing Volume</Trans>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<AdminLeaderboardTable
|
||||||
|
signingVolume={signingVolume}
|
||||||
|
totalPages={totalPages}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,59 +0,0 @@
|
|||||||
import { getOrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
|
|
||||||
import type { DateRange } from '@documenso/lib/types/search-params';
|
|
||||||
import { getAdminOrganisation } from '@documenso/trpc/server/admin-router/get-admin-organisation';
|
|
||||||
|
|
||||||
import { OrganisationInsightsTable } from '~/components/tables/organisation-insights-table';
|
|
||||||
|
|
||||||
import type { Route } from './+types/organisation-insights.$id';
|
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
|
||||||
const { id } = params;
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
const page = Number(url.searchParams.get('page')) || 1;
|
|
||||||
const perPage = Number(url.searchParams.get('perPage')) || 10;
|
|
||||||
const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange;
|
|
||||||
const view = (url.searchParams.get('view') || 'teams') as 'teams' | 'users' | 'documents';
|
|
||||||
|
|
||||||
const [insights, organisation] = await Promise.all([
|
|
||||||
getOrganisationDetailedInsights({
|
|
||||||
organisationId: id,
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
dateRange,
|
|
||||||
view,
|
|
||||||
}),
|
|
||||||
getAdminOrganisation({ organisationId: id }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
organisationId: id,
|
|
||||||
organisationName: organisation.name,
|
|
||||||
insights,
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
dateRange,
|
|
||||||
view,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OrganisationInsights({ loaderData }: Route.ComponentProps) {
|
|
||||||
const { insights, page, perPage, dateRange, view, organisationName } = loaderData;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-4xl font-semibold">{organisationName}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="mt-8">
|
|
||||||
<OrganisationInsightsTable
|
|
||||||
insights={insights}
|
|
||||||
page={page}
|
|
||||||
perPage={perPage}
|
|
||||||
dateRange={dateRange}
|
|
||||||
view={view}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { getOrganisationInsights } from '@documenso/lib/server-only/admin/get-signing-volume';
|
|
||||||
import type { DateRange } from '@documenso/lib/types/search-params';
|
|
||||||
|
|
||||||
import { DateRangeFilter } from '~/components/filters/date-range-filter';
|
|
||||||
import {
|
|
||||||
AdminOrganisationOverviewTable,
|
|
||||||
type OrganisationOverview,
|
|
||||||
} from '~/components/tables/admin-organisation-overview-table';
|
|
||||||
|
|
||||||
import type { Route } from './+types/organisation-insights._index';
|
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
|
|
||||||
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
|
|
||||||
|
|
||||||
const isSortOrder = (value: string): value is 'asc' | 'desc' =>
|
|
||||||
value === 'asc' || value === 'desc';
|
|
||||||
const isSortBy = (value: string): value is 'name' | 'createdAt' | 'signingVolume' =>
|
|
||||||
value === 'name' || value === 'createdAt' || value === 'signingVolume';
|
|
||||||
|
|
||||||
const sortOrder: 'asc' | 'desc' = isSortOrder(rawSortOrder) ? rawSortOrder : 'desc';
|
|
||||||
const sortBy: 'name' | 'createdAt' | 'signingVolume' = isSortBy(rawSortBy)
|
|
||||||
? rawSortBy
|
|
||||||
: 'signingVolume';
|
|
||||||
|
|
||||||
const page = Number(url.searchParams.get('page')) || 1;
|
|
||||||
const perPage = Number(url.searchParams.get('perPage')) || 10;
|
|
||||||
const search = url.searchParams.get('search') || '';
|
|
||||||
const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange;
|
|
||||||
|
|
||||||
const { organisations, totalPages } = await getOrganisationInsights({
|
|
||||||
search,
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
dateRange,
|
|
||||||
});
|
|
||||||
|
|
||||||
const typedOrganisations: OrganisationOverview[] = organisations.map((item) => ({
|
|
||||||
id: String(item.id),
|
|
||||||
name: item.name || '',
|
|
||||||
signingVolume: item.signingVolume,
|
|
||||||
createdAt: item.createdAt || new Date(),
|
|
||||||
customerId: item.customerId || '',
|
|
||||||
subscriptionStatus: item.subscriptionStatus,
|
|
||||||
teamCount: item.teamCount || 0,
|
|
||||||
memberCount: item.memberCount || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
organisations: typedOrganisations,
|
|
||||||
totalPages,
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
dateRange,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Organisations({ loaderData }: Route.ComponentProps) {
|
|
||||||
const { organisations, totalPages, page, perPage, sortBy, sortOrder, dateRange } = loaderData;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-4xl font-semibold">
|
|
||||||
<Trans>Organisation Insights</Trans>
|
|
||||||
</h2>
|
|
||||||
<DateRangeFilter currentRange={dateRange} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
<AdminOrganisationOverviewTable
|
|
||||||
organisations={organisations}
|
|
||||||
totalPages={totalPages}
|
|
||||||
page={page}
|
|
||||||
perPage={perPage}
|
|
||||||
sortBy={sortBy}
|
|
||||||
sortOrder={sortOrder}
|
|
||||||
dateRange={dateRange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -142,7 +142,8 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Organisation not found`,
|
heading: msg`Organisation not found`,
|
||||||
subHeading: msg`404 Organisation not found`,
|
subHeading: msg`404 Organisation not found`,
|
||||||
message: msg`The organisation you are looking for may have been removed, renamed or may have never existed.`,
|
message: msg`The organisation you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -59,7 +59,8 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`User not found`,
|
heading: msg`User not found`,
|
||||||
subHeading: msg`404 User not found`,
|
subHeading: msg`404 User not found`,
|
||||||
message: msg`The user you are looking for may have been removed, renamed or may have never existed.`,
|
message: msg`The user you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -117,7 +117,8 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Email domain not found`,
|
heading: msg`Email domain not found`,
|
||||||
subHeading: msg`404 Email domain not found`,
|
subHeading: msg`404 Email domain not found`,
|
||||||
message: msg`The email domain you are looking for may have been removed, renamed or may have never existed.`,
|
message: msg`The email domain you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -89,7 +89,8 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Organisation group not found`,
|
heading: msg`Organisation group not found`,
|
||||||
subHeading: msg`404 Organisation group not found`,
|
subHeading: msg`404 Organisation group not found`,
|
||||||
message: msg`The organisation group you are looking for may have been removed, renamed or may have never existed.`,
|
message: msg`The organisation group you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -60,7 +60,8 @@ export default function Layout() {
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Team not found`,
|
heading: msg`Team not found`,
|
||||||
subHeading: msg`404 Team not found`,
|
subHeading: msg`404 Team not found`,
|
||||||
message: msg`The team you are looking for may have been removed, renamed or may have never existed.`,
|
message: msg`The team you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -71,7 +71,8 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Not found`,
|
heading: msg`Not found`,
|
||||||
subHeading: msg`404 Not found`,
|
subHeading: msg`404 Not found`,
|
||||||
message: msg`The document you are looking for may have been removed, renamed or may have never existed.`,
|
message: msg`The document you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
@ -126,11 +127,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
position="bottom"
|
position="bottom"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<Plural
|
<Trans>{envelope.recipients.length} Recipient(s)</Trans>
|
||||||
value={envelope.recipients.length}
|
|
||||||
one="# Recipient"
|
|
||||||
other="# Recipients"
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
@ -150,13 +147,8 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||||
<EnvelopeRenderProvider
|
<EnvelopeRenderProvider
|
||||||
envelope={envelope}
|
envelope={envelope}
|
||||||
token={undefined}
|
fields={envelope.status == DocumentStatus.COMPLETED ? [] : envelope.fields}
|
||||||
fields={envelope.fields}
|
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||||
recipients={envelope.recipients}
|
|
||||||
overrideSettings={{
|
|
||||||
showRecipientSigningStatus: true,
|
|
||||||
showRecipientTooltip: true,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isMultiEnvelopeItem && (
|
{isMultiEnvelopeItem && (
|
||||||
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
||||||
@ -164,10 +156,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
|
|
||||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewerKonvaLazy
|
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||||
renderer="preview"
|
|
||||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
@ -189,10 +178,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
envelopeItem={envelope.envelopeItems[0]}
|
document={envelope}
|
||||||
token={undefined}
|
|
||||||
key={envelope.envelopeItems[0].id}
|
key={envelope.envelopeItems[0].id}
|
||||||
version="signed"
|
documentData={envelope.envelopeItems[0].documentData}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -82,7 +82,8 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Not found`,
|
heading: msg`Not found`,
|
||||||
subHeading: msg`404 Not found`,
|
subHeading: msg`404 Not found`,
|
||||||
message: msg`The document you are looking for may have been removed, renamed or may have never existed.`,
|
message: msg`The document you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
@ -100,9 +101,8 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
|||||||
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
||||||
<EnvelopeRenderProvider
|
<EnvelopeRenderProvider
|
||||||
envelope={envelope}
|
envelope={envelope}
|
||||||
token={undefined}
|
|
||||||
fields={envelope.fields}
|
fields={envelope.fields}
|
||||||
recipients={envelope.recipients}
|
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||||
>
|
>
|
||||||
<EnvelopeEditor />
|
<EnvelopeEditor />
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
|
||||||
import { FolderType, OrganisationType } from '@prisma/client';
|
import { FolderType, OrganisationType } from '@prisma/client';
|
||||||
import { useParams, useSearchParams } from 'react-router';
|
import { useParams, useSearchParams } from 'react-router';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
@ -19,9 +18,9 @@ import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/av
|
|||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
||||||
|
import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
|
||||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
|
||||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||||
import { PeriodSelector } from '~/components/general/period-selector';
|
import { PeriodSelector } from '~/components/general/period-selector';
|
||||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||||
@ -109,8 +108,9 @@ export default function DocumentsPage() {
|
|||||||
}
|
}
|
||||||
}, [data?.stats]);
|
}, [data?.stats]);
|
||||||
|
|
||||||
|
// Todo: Envelopes - Change the dropzone wrapper to create to V2 documents after we're ready.
|
||||||
return (
|
return (
|
||||||
<EnvelopeDropZoneWrapper type={EnvelopeType.DOCUMENT}>
|
<DocumentDropZoneWrapper>
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
|
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
|
||||||
|
|
||||||
@ -210,6 +210,6 @@ export default function DocumentsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</EnvelopeDropZoneWrapper>
|
</DocumentDropZoneWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,7 +109,8 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Webhook not found`,
|
heading: msg`Webhook not found`,
|
||||||
subHeading: msg`404 Webhook not found`,
|
subHeading: msg`404 Webhook not found`,
|
||||||
message: msg`The webhook you are looking for may have been removed, renamed or may have never existed.`,
|
message: msg`The webhook you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -66,7 +66,8 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Not found`,
|
heading: msg`Not found`,
|
||||||
subHeading: msg`404 Not found`,
|
subHeading: msg`404 Not found`,
|
||||||
message: msg`The template you are looking for may have been removed, renamed or may have never existed.`,
|
message: msg`The template you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
@ -169,12 +170,8 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||||
<EnvelopeRenderProvider
|
<EnvelopeRenderProvider
|
||||||
envelope={envelope}
|
envelope={envelope}
|
||||||
token={undefined}
|
|
||||||
fields={envelope.fields}
|
fields={envelope.fields}
|
||||||
recipients={envelope.recipients}
|
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||||
overrideSettings={{
|
|
||||||
showRecipientTooltip: true,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isMultiEnvelopeItem && (
|
{isMultiEnvelopeItem && (
|
||||||
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
||||||
@ -182,10 +179,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
|
|
||||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewerKonvaLazy
|
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||||
renderer="preview"
|
|
||||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
@ -206,10 +200,9 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
envelopeItem={envelope.envelopeItems[0]}
|
document={envelope}
|
||||||
token={undefined}
|
|
||||||
version="signed"
|
|
||||||
key={envelope.envelopeItems[0].id}
|
key={envelope.envelopeItems[0].id}
|
||||||
|
documentData={envelope.envelopeItems[0].documentData}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
|
||||||
import { Bird } from 'lucide-react';
|
import { Bird } from 'lucide-react';
|
||||||
import { useParams, useSearchParams } from 'react-router';
|
import { useParams, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
@ -9,8 +8,8 @@ import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/t
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
|
||||||
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
|
||||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||||
|
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
|
||||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@ -38,7 +37,7 @@ export default function TemplatesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
|
<TemplateDropZoneWrapper>
|
||||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||||
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
|
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
|
||||||
|
|
||||||
@ -86,6 +85,6 @@ export default function TemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</EnvelopeDropZoneWrapper>
|
</TemplateDropZoneWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -184,7 +184,6 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
|||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
recipient={directTemplateRecipient}
|
recipient={directTemplateRecipient}
|
||||||
isDirectTemplate={true}
|
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
@ -246,7 +245,7 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
<EnvelopeRenderProvider envelope={envelope}>
|
||||||
<DocumentSigningPageViewV2 />
|
<DocumentSigningPageViewV2 />
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
</DocumentSigningAuthProvider>
|
</DocumentSigningAuthProvider>
|
||||||
|
|||||||
@ -492,7 +492,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
<EnvelopeRenderProvider envelope={envelope}>
|
||||||
<DocumentSigningPageViewV2 />
|
<DocumentSigningPageViewV2 />
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
</DocumentSigningAuthProvider>
|
</DocumentSigningAuthProvider>
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
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, DownloadIcon } from 'lucide-react';
|
import { CheckCircle2, Clock8, FileSearch } 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';
|
||||||
|
|
||||||
@ -19,13 +20,14 @@ 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';
|
||||||
|
|
||||||
@ -205,16 +207,24 @@ 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) ? (
|
||||||
<EnvelopeDownloadDialog
|
<DocumentDownloadButton
|
||||||
envelopeId={document.envelopeId}
|
className="flex-1"
|
||||||
envelopeStatus={document.status}
|
fileName={document.title}
|
||||||
envelopeItems={document.envelopeItems}
|
documentData={document.documentData}
|
||||||
token={recipient?.token}
|
disabled={!isDocumentCompleted(document.status)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DocumentDialog
|
||||||
|
documentData={document.documentData}
|
||||||
trigger={
|
trigger={
|
||||||
<Button type="button" variant="outline" className="flex-1">
|
<Button
|
||||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
className="text-[11px]"
|
||||||
<Trans>Download</Trans>
|
title={_(msg`Signatures will appear once the document has been completed`)}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<FileSearch className="mr-2 h-5 w-5" strokeWidth={1.7} />
|
||||||
|
<Trans>View Original Document</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -60,7 +60,6 @@ export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) =>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
document,
|
document,
|
||||||
token: slug,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,7 +74,7 @@ export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SharePage() {
|
export default function SharePage() {
|
||||||
const { document, token } = useLoaderData<typeof loader>();
|
const { document } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
if (document) {
|
if (document) {
|
||||||
return (
|
return (
|
||||||
@ -87,7 +86,6 @@ 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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
|
|
||||||
@ -11,7 +9,6 @@ import {
|
|||||||
OIDC_PROVIDER_LABEL,
|
OIDC_PROVIDER_LABEL,
|
||||||
} from '@documenso/lib/constants/auth';
|
} from '@documenso/lib/constants/auth';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
|
||||||
|
|
||||||
import { SignInForm } from '~/components/forms/signin';
|
import { SignInForm } from '~/components/forms/signin';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@ -31,12 +28,8 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||||
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
||||||
|
|
||||||
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
|
||||||
|
|
||||||
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
throw redirect(returnTo || '/');
|
throw redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -44,28 +37,12 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
isMicrosoftSSOEnabled,
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
oidcProviderLabel,
|
oidcProviderLabel,
|
||||||
returnTo,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||||
const {
|
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
|
||||||
isGoogleSSOEnabled,
|
loaderData;
|
||||||
isMicrosoftSSOEnabled,
|
|
||||||
isOIDCSSOEnabled,
|
|
||||||
oidcProviderLabel,
|
|
||||||
returnTo,
|
|
||||||
} = loaderData;
|
|
||||||
|
|
||||||
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hash = window.location.hash.slice(1);
|
|
||||||
|
|
||||||
const params = new URLSearchParams(hash);
|
|
||||||
|
|
||||||
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div className="w-screen max-w-lg px-4">
|
||||||
@ -84,17 +61,13 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
|||||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
oidcProviderLabel={oidcProviderLabel}
|
oidcProviderLabel={oidcProviderLabel}
|
||||||
returnTo={returnTo}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
{env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link
|
<Link to="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
|
||||||
to={returnTo ? `/signup?returnTo=${encodeURIComponent(returnTo)}` : '/signup'}
|
|
||||||
className="text-documenso-700 duration-200 hover:opacity-70"
|
|
||||||
>
|
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</Trans>
|
</Trans>
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
IS_OIDC_SSO_ENABLED,
|
IS_OIDC_SSO_ENABLED,
|
||||||
} from '@documenso/lib/constants/auth';
|
} from '@documenso/lib/constants/auth';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
|
||||||
|
|
||||||
import { SignUpForm } from '~/components/forms/signup';
|
import { SignUpForm } from '~/components/forms/signup';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@ -17,7 +16,7 @@ export function meta() {
|
|||||||
return appMetaTags('Sign Up');
|
return appMetaTags('Sign Up');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loader({ request }: Route.LoaderArgs) {
|
export function loader() {
|
||||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||||
|
|
||||||
// SSR env variables.
|
// SSR env variables.
|
||||||
@ -29,20 +28,15 @@ export function loader({ request }: Route.LoaderArgs) {
|
|||||||
throw redirect('/signin');
|
throw redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
|
||||||
|
|
||||||
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
isMicrosoftSSOEnabled,
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
returnTo,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignUp({ loaderData }: Route.ComponentProps) {
|
export default function SignUp({ loaderData }: Route.ComponentProps) {
|
||||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, returnTo } = loaderData;
|
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled } = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SignUpForm
|
<SignUpForm
|
||||||
@ -50,7 +44,6 @@ export default function SignUp({ loaderData }: Route.ComponentProps) {
|
|||||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
returnTo={returnTo}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,14 +2,11 @@ import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
IS_GOOGLE_SSO_ENABLED,
|
IS_GOOGLE_SSO_ENABLED,
|
||||||
IS_MICROSOFT_SSO_ENABLED,
|
|
||||||
IS_OIDC_SSO_ENABLED,
|
IS_OIDC_SSO_ENABLED,
|
||||||
OIDC_PROVIDER_LABEL,
|
OIDC_PROVIDER_LABEL,
|
||||||
} from '@documenso/lib/constants/auth';
|
} from '@documenso/lib/constants/auth';
|
||||||
|
|
||||||
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
|
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
|
||||||
import { EmbedDocumentCompleted } from '~/components/embed/embed-document-completed';
|
|
||||||
import { EmbedDocumentRejected } from '~/components/embed/embed-document-rejected';
|
|
||||||
import { EmbedDocumentWaitingForTurn } from '~/components/embed/embed-document-waiting-for-turn';
|
import { EmbedDocumentWaitingForTurn } from '~/components/embed/embed-document-waiting-for-turn';
|
||||||
import { EmbedPaywall } from '~/components/embed/embed-paywall';
|
import { EmbedPaywall } from '~/components/embed/embed-paywall';
|
||||||
|
|
||||||
@ -32,13 +29,11 @@ export function headers({ loaderHeaders }: Route.HeadersArgs) {
|
|||||||
export function loader() {
|
export function loader() {
|
||||||
// SSR env variables.
|
// SSR env variables.
|
||||||
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
||||||
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
|
|
||||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||||
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
isMicrosoftSSOEnabled,
|
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
oidcProviderLabel,
|
oidcProviderLabel,
|
||||||
};
|
};
|
||||||
@ -49,19 +44,15 @@ export default function Layout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
|
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
|
||||||
loaderData || {};
|
|
||||||
|
|
||||||
const error = useRouteError();
|
const error = useRouteError();
|
||||||
|
|
||||||
console.log({ routeError: error });
|
|
||||||
|
|
||||||
if (isRouteErrorResponse(error)) {
|
if (isRouteErrorResponse(error)) {
|
||||||
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
|
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
|
||||||
return (
|
return (
|
||||||
<EmbedAuthenticationRequired
|
<EmbedAuthenticationRequired
|
||||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
|
||||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
oidcProviderLabel={oidcProviderLabel}
|
oidcProviderLabel={oidcProviderLabel}
|
||||||
email={error.data.email}
|
email={error.data.email}
|
||||||
@ -77,16 +68,6 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
|||||||
if (error.status === 403 && error.data.type === 'embed-waiting-for-turn') {
|
if (error.status === 403 && error.data.type === 'embed-waiting-for-turn') {
|
||||||
return <EmbedDocumentWaitingForTurn />;
|
return <EmbedDocumentWaitingForTurn />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// !: Not used at the moment, may be removed in the future.
|
|
||||||
if (error.status === 403 && error.data.type === 'embed-document-rejected') {
|
|
||||||
return <EmbedDocumentRejected />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// !: Not used at the moment, may be removed in the future.
|
|
||||||
if (error.status === 403 && error.data.type === 'embed-document-completed') {
|
|
||||||
return <EmbedDocumentCompleted name={error.data.name} signature={error.data.signature} />;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div>Not Found</div>;
|
return <div>Not Found</div>;
|
||||||
|
|||||||
@ -1,332 +0,0 @@
|
|||||||
import { data } from 'react-router';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
|
|
||||||
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
|
|
||||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
|
||||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
import { EmbedDirectTemplateClientPage } from '~/components/embed/embed-direct-template-client-page';
|
|
||||||
import { EmbedSignDocumentV2ClientPage } from '~/components/embed/embed-document-signing-page-v2';
|
|
||||||
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
|
||||||
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
|
||||||
import { DocumentSigningRecipientProvider } from '~/components/general/document-signing/document-signing-recipient-provider';
|
|
||||||
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
|
|
||||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
|
||||||
|
|
||||||
import type { Route } from './+types/direct.$token';
|
|
||||||
|
|
||||||
async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
|
||||||
if (!params.token) {
|
|
||||||
throw new Response('Not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = params.token;
|
|
||||||
|
|
||||||
const template = await getTemplateByDirectLinkToken({
|
|
||||||
token,
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
// `template.directLink` is always available but we're doing this to
|
|
||||||
// satisfy the type checker.
|
|
||||||
if (!template || !template.directLink) {
|
|
||||||
throw new Response('Not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: template.teamId });
|
|
||||||
|
|
||||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
|
||||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
|
||||||
|
|
||||||
// TODO: Make this more robust, we need to ensure the owner is either
|
|
||||||
// TODO: the member of a team that has an active subscription, is an early
|
|
||||||
// TODO: adopter or is an enterprise user.
|
|
||||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-paywall',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = await getOptionalSession(request);
|
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
|
||||||
documentAuth: template.authOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
|
|
||||||
match(auth)
|
|
||||||
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
|
|
||||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
|
|
||||||
.exhaustive(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isAccessAuthValid) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-authentication-required',
|
|
||||||
returnTo: `/embed/direct/${token}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { directTemplateRecipientId } = template.directLink;
|
|
||||||
|
|
||||||
const recipient = template.recipients.find(
|
|
||||||
(recipient) => recipient.id === directTemplateRecipientId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!recipient) {
|
|
||||||
throw new Response('Not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
template,
|
|
||||||
recipient,
|
|
||||||
fields,
|
|
||||||
hidePoweredBy,
|
|
||||||
allowEmbedSigningWhitelabel,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
|
||||||
if (!params.token) {
|
|
||||||
throw new Response('Not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = params.token;
|
|
||||||
|
|
||||||
const { user } = await getOptionalSession(request);
|
|
||||||
|
|
||||||
const envelopeForSigning = await getEnvelopeForDirectTemplateSigning({
|
|
||||||
token,
|
|
||||||
userId: user?.id,
|
|
||||||
})
|
|
||||||
.then((envelopeForSigning) => {
|
|
||||||
return {
|
|
||||||
isDocumentAccessValid: true,
|
|
||||||
...envelopeForSigning,
|
|
||||||
} as const;
|
|
||||||
})
|
|
||||||
.catch(async (e) => {
|
|
||||||
const error = AppError.parseError(e);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
|
||||||
const requiredAccessData = await getEnvelopeRequiredAccessData({ token });
|
|
||||||
|
|
||||||
return {
|
|
||||||
isDocumentAccessValid: false,
|
|
||||||
...requiredAccessData,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Response('Not Found', { status: 404 });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!envelopeForSigning.isDocumentAccessValid) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-authentication-required',
|
|
||||||
email: envelopeForSigning.recipientEmail,
|
|
||||||
returnTo: `/embed/direct/${token}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { envelope, recipient } = envelopeForSigning;
|
|
||||||
|
|
||||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: envelope.teamId });
|
|
||||||
|
|
||||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
|
||||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
|
||||||
|
|
||||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-paywall',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
|
||||||
documentAuth: envelope.authOptions,
|
|
||||||
recipientAuth: recipient.authOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
|
||||||
match(accesssAuth)
|
|
||||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
|
||||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
|
|
||||||
.exhaustive(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isAccessAuthValid) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-authentication-required',
|
|
||||||
email: user?.email || recipient.email,
|
|
||||||
returnTo: `/embed/direct/${token}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
envelopeForSigning,
|
|
||||||
hidePoweredBy,
|
|
||||||
allowEmbedSigningWhitelabel,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loader(loaderArgs: Route.LoaderArgs) {
|
|
||||||
const { token } = loaderArgs.params;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new Response('Not Found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not efficient but works for now until we remove v1.
|
|
||||||
const foundDirectLink = await prisma.templateDirectLink.findFirst({
|
|
||||||
where: {
|
|
||||||
token,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
envelope: {
|
|
||||||
select: {
|
|
||||||
internalVersion: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!foundDirectLink) {
|
|
||||||
throw new Response('Not Found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (foundDirectLink.envelope.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EmbedDirectTemplatePage() {
|
|
||||||
const { version, payload } = useSuperLoaderData<typeof loader>();
|
|
||||||
|
|
||||||
if (version === 1) {
|
|
||||||
return <EmbedDirectTemplatePageV1 data={payload} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <EmbedDirectTemplatePageV2 data={payload} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EmbedDirectTemplatePageV1 = ({
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
data: Awaited<ReturnType<typeof handleV1Loader>>;
|
|
||||||
}) => {
|
|
||||||
const { token, user, template, recipient, fields, hidePoweredBy, allowEmbedSigningWhitelabel } =
|
|
||||||
data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DocumentSigningProvider
|
|
||||||
email={user?.email}
|
|
||||||
fullName={user?.name}
|
|
||||||
signature={user?.signature}
|
|
||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
|
||||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
|
||||||
>
|
|
||||||
<DocumentSigningAuthProvider
|
|
||||||
documentAuthOptions={template.authOptions}
|
|
||||||
recipient={recipient}
|
|
||||||
user={user}
|
|
||||||
>
|
|
||||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
|
||||||
<EmbedDirectTemplateClientPage
|
|
||||||
token={token}
|
|
||||||
envelopeId={template.envelopeId}
|
|
||||||
updatedAt={template.updatedAt}
|
|
||||||
envelopeItems={template.envelopeItems}
|
|
||||||
recipient={recipient}
|
|
||||||
fields={fields}
|
|
||||||
metadata={template.templateMeta}
|
|
||||||
hidePoweredBy={hidePoweredBy}
|
|
||||||
allowWhiteLabelling={allowEmbedSigningWhitelabel}
|
|
||||||
/>
|
|
||||||
</DocumentSigningRecipientProvider>
|
|
||||||
</DocumentSigningAuthProvider>
|
|
||||||
</DocumentSigningProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EmbedDirectTemplatePageV2 = ({
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
data: Awaited<ReturnType<typeof handleV2Loader>>;
|
|
||||||
}) => {
|
|
||||||
const { token, user, envelopeForSigning, hidePoweredBy, allowEmbedSigningWhitelabel } = data;
|
|
||||||
|
|
||||||
const { envelope, recipient } = envelopeForSigning;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EnvelopeSigningProvider
|
|
||||||
envelopeData={envelopeForSigning}
|
|
||||||
email={user?.email}
|
|
||||||
fullName={user?.name}
|
|
||||||
signature={user?.signature}
|
|
||||||
>
|
|
||||||
<DocumentSigningAuthProvider
|
|
||||||
documentAuthOptions={envelope.authOptions}
|
|
||||||
recipient={recipient}
|
|
||||||
user={user}
|
|
||||||
isDirectTemplate={true}
|
|
||||||
>
|
|
||||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
|
||||||
<EmbedSignDocumentV2ClientPage
|
|
||||||
hidePoweredBy={hidePoweredBy}
|
|
||||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
|
||||||
/>
|
|
||||||
</EnvelopeRenderProvider>
|
|
||||||
</DocumentSigningAuthProvider>
|
|
||||||
</EnvelopeSigningProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
138
apps/remix/app/routes/embed+/_v0+/direct.$url.tsx
Normal file
138
apps/remix/app/routes/embed+/_v0+/direct.$url.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { data } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||||
|
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||||
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
|
||||||
|
import { EmbedDirectTemplateClientPage } from '~/components/embed/embed-direct-template-client-page';
|
||||||
|
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||||
|
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||||
|
import { DocumentSigningRecipientProvider } from '~/components/general/document-signing/document-signing-recipient-provider';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
|
import type { Route } from './+types/direct.$url';
|
||||||
|
|
||||||
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
if (!params.url) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = params.url;
|
||||||
|
|
||||||
|
const template = await getTemplateByDirectLinkToken({
|
||||||
|
token,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
// `template.directLink` is always available but we're doing this to
|
||||||
|
// satisfy the type checker.
|
||||||
|
if (!template || !template.directLink) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: template.teamId });
|
||||||
|
|
||||||
|
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||||
|
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||||
|
|
||||||
|
// TODO: Make this more robust, we need to ensure the owner is either
|
||||||
|
// TODO: the member of a team that has an active subscription, is an early
|
||||||
|
// TODO: adopter or is an enterprise user.
|
||||||
|
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-paywall',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await getOptionalSession(request);
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: template.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
|
||||||
|
match(auth)
|
||||||
|
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
|
||||||
|
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
|
||||||
|
.exhaustive(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAccessAuthValid) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-authentication-required',
|
||||||
|
email: user?.email,
|
||||||
|
returnTo: `/embed/direct/${token}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { directTemplateRecipientId } = template.directLink;
|
||||||
|
|
||||||
|
const recipient = template.recipients.find(
|
||||||
|
(recipient) => recipient.id === directTemplateRecipientId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
template,
|
||||||
|
recipient,
|
||||||
|
fields,
|
||||||
|
hidePoweredBy,
|
||||||
|
allowEmbedSigningWhitelabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmbedDirectTemplatePage() {
|
||||||
|
const { token, user, template, recipient, fields, hidePoweredBy, allowEmbedSigningWhitelabel } =
|
||||||
|
useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentSigningProvider
|
||||||
|
email={user?.email}
|
||||||
|
fullName={user?.name}
|
||||||
|
signature={user?.signature}
|
||||||
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||||
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||||
|
>
|
||||||
|
<DocumentSigningAuthProvider
|
||||||
|
documentAuthOptions={template.authOptions}
|
||||||
|
recipient={recipient}
|
||||||
|
user={user}
|
||||||
|
>
|
||||||
|
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||||
|
<EmbedDirectTemplateClientPage
|
||||||
|
token={token}
|
||||||
|
envelopeId={template.envelopeId}
|
||||||
|
updatedAt={template.updatedAt}
|
||||||
|
documentData={template.templateDocumentData}
|
||||||
|
recipient={recipient}
|
||||||
|
fields={fields}
|
||||||
|
metadata={template.templateMeta}
|
||||||
|
hidePoweredBy={hidePoweredBy}
|
||||||
|
allowWhiteLabelling={allowEmbedSigningWhitelabel}
|
||||||
|
/>
|
||||||
|
</DocumentSigningRecipientProvider>
|
||||||
|
</DocumentSigningAuthProvider>
|
||||||
|
</DocumentSigningProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,394 +0,0 @@
|
|||||||
import { RecipientRole } from '@prisma/client';
|
|
||||||
import { data } from 'react-router';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
|
||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
|
||||||
import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
|
||||||
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
|
|
||||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
|
||||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
|
||||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
|
||||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
import { EmbedSignDocumentV1ClientPage } from '~/components/embed/embed-document-signing-page-v1';
|
|
||||||
import { EmbedSignDocumentV2ClientPage } from '~/components/embed/embed-document-signing-page-v2';
|
|
||||||
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-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 { getOptionalLoaderContext } from '../../../../server/utils/get-loader-session';
|
|
||||||
import type { Route } from './+types/sign.$token';
|
|
||||||
|
|
||||||
async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
|
||||||
const { requestMetadata } = getOptionalLoaderContext();
|
|
||||||
|
|
||||||
if (!params.token) {
|
|
||||||
throw new Response('Not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = params.token;
|
|
||||||
|
|
||||||
const { user } = await getOptionalSession(request);
|
|
||||||
|
|
||||||
const [document, fields, recipient, completedFields] = await Promise.all([
|
|
||||||
getDocumentAndSenderByToken({
|
|
||||||
token,
|
|
||||||
userId: user?.id,
|
|
||||||
requireAccessAuth: false,
|
|
||||||
}).catch(() => null),
|
|
||||||
getFieldsForToken({ token }),
|
|
||||||
getRecipientByToken({ token }).catch(() => null),
|
|
||||||
getCompletedFieldsForToken({ token }).catch(() => []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// `document.directLink` is always available but we're doing this to
|
|
||||||
// satisfy the type checker.
|
|
||||||
if (!document || !recipient) {
|
|
||||||
throw new Response('Not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: document.teamId });
|
|
||||||
|
|
||||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
|
||||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
|
||||||
|
|
||||||
// TODO: Make this more robust, we need to ensure the owner is either
|
|
||||||
// TODO: the member of a team that has an active subscription, is an early
|
|
||||||
// TODO: adopter or is an enterprise user.
|
|
||||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-paywall',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
|
||||||
documentAuth: document.authOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
|
||||||
match(accesssAuth)
|
|
||||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
|
||||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
|
|
||||||
.exhaustive(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isAccessAuthValid) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-authentication-required',
|
|
||||||
email: user?.email || recipient.email,
|
|
||||||
returnTo: `/embed/sign/${token}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
|
|
||||||
|
|
||||||
if (!isRecipientsTurnToSign) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-waiting-for-turn',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await viewedDocument({
|
|
||||||
token,
|
|
||||||
requestMetadata,
|
|
||||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const allRecipients =
|
|
||||||
recipient.role === RecipientRole.ASSISTANT
|
|
||||||
? await getRecipientsForAssistant({
|
|
||||||
token,
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
document,
|
|
||||||
allRecipients,
|
|
||||||
recipient,
|
|
||||||
fields,
|
|
||||||
completedFields,
|
|
||||||
hidePoweredBy,
|
|
||||||
allowEmbedSigningWhitelabel,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
|
||||||
const { requestMetadata } = getOptionalLoaderContext();
|
|
||||||
|
|
||||||
if (!params.token) {
|
|
||||||
throw new Response('Not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = params.token;
|
|
||||||
|
|
||||||
const { user } = await getOptionalSession(request);
|
|
||||||
|
|
||||||
const envelopeForSigning = await getEnvelopeForRecipientSigning({
|
|
||||||
token,
|
|
||||||
userId: user?.id,
|
|
||||||
})
|
|
||||||
.then((envelopeForSigning) => {
|
|
||||||
return {
|
|
||||||
isDocumentAccessValid: true,
|
|
||||||
...envelopeForSigning,
|
|
||||||
} as const;
|
|
||||||
})
|
|
||||||
.catch(async (e) => {
|
|
||||||
const error = AppError.parseError(e);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
|
||||||
const requiredAccessData = await getEnvelopeRequiredAccessData({ token });
|
|
||||||
|
|
||||||
return {
|
|
||||||
isDocumentAccessValid: false,
|
|
||||||
...requiredAccessData,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Response('Not Found', { status: 404 });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!envelopeForSigning.isDocumentAccessValid) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-authentication-required',
|
|
||||||
email: envelopeForSigning.recipientEmail,
|
|
||||||
returnTo: `/embed/sign/${token}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { envelope, recipient, isRecipientsTurn } = envelopeForSigning;
|
|
||||||
|
|
||||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: envelope.teamId });
|
|
||||||
|
|
||||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
|
||||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
|
||||||
|
|
||||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-paywall',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isRecipientsTurn) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-waiting-for-turn',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
|
||||||
documentAuth: envelope.authOptions,
|
|
||||||
recipientAuth: recipient.authOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
|
||||||
match(accesssAuth)
|
|
||||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
|
||||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
|
|
||||||
.exhaustive(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isAccessAuthValid) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-authentication-required',
|
|
||||||
email: user?.email || recipient.email,
|
|
||||||
returnTo: `/embed/sign/${token}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await viewedDocument({
|
|
||||||
token,
|
|
||||||
requestMetadata,
|
|
||||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
return {
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
envelopeForSigning,
|
|
||||||
hidePoweredBy,
|
|
||||||
allowEmbedSigningWhitelabel,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loader(loaderArgs: Route.LoaderArgs) {
|
|
||||||
const { token } = loaderArgs.params;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new Response('Not Found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not efficient but works for now until we remove v1.
|
|
||||||
const foundRecipient = await prisma.recipient.findFirst({
|
|
||||||
where: {
|
|
||||||
token,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
envelope: {
|
|
||||||
select: {
|
|
||||||
internalVersion: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!foundRecipient) {
|
|
||||||
throw new Response('Not Found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (foundRecipient.envelope.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EmbedSignDocumentPage() {
|
|
||||||
const { version, payload } = useSuperLoaderData<typeof loader>();
|
|
||||||
|
|
||||||
if (version === 1) {
|
|
||||||
return <EmbedSignDocumentPageV1 data={payload} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <EmbedSignDocumentPageV2 data={payload} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EmbedSignDocumentPageV1 = ({
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
data: Awaited<ReturnType<typeof handleV1Loader>>;
|
|
||||||
}) => {
|
|
||||||
const {
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
document,
|
|
||||||
allRecipients,
|
|
||||||
recipient,
|
|
||||||
fields,
|
|
||||||
completedFields,
|
|
||||||
hidePoweredBy,
|
|
||||||
allowEmbedSigningWhitelabel,
|
|
||||||
} = data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DocumentSigningProvider
|
|
||||||
email={recipient.email}
|
|
||||||
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
|
||||||
signature={user?.email === recipient.email ? user?.signature : undefined}
|
|
||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
|
||||||
>
|
|
||||||
<DocumentSigningAuthProvider
|
|
||||||
documentAuthOptions={document.authOptions}
|
|
||||||
recipient={recipient}
|
|
||||||
user={user}
|
|
||||||
>
|
|
||||||
<EmbedSignDocumentV1ClientPage
|
|
||||||
token={token}
|
|
||||||
documentId={document.id}
|
|
||||||
envelopeId={document.envelopeId}
|
|
||||||
envelopeItems={document.envelopeItems}
|
|
||||||
recipient={recipient}
|
|
||||||
fields={fields}
|
|
||||||
completedFields={completedFields}
|
|
||||||
metadata={document.documentMeta}
|
|
||||||
isCompleted={isDocumentCompleted(document.status)}
|
|
||||||
hidePoweredBy={hidePoweredBy}
|
|
||||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
|
||||||
allRecipients={allRecipients}
|
|
||||||
/>
|
|
||||||
</DocumentSigningAuthProvider>
|
|
||||||
</DocumentSigningProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EmbedSignDocumentPageV2 = ({
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
data: Awaited<ReturnType<typeof handleV2Loader>>;
|
|
||||||
}) => {
|
|
||||||
const { token, user, envelopeForSigning, hidePoweredBy, allowEmbedSigningWhitelabel } = data;
|
|
||||||
|
|
||||||
const { envelope, recipient } = envelopeForSigning;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EnvelopeSigningProvider
|
|
||||||
envelopeData={envelopeForSigning}
|
|
||||||
email={recipient.email}
|
|
||||||
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
|
||||||
signature={user?.email === recipient.email ? user?.signature : undefined}
|
|
||||||
>
|
|
||||||
<DocumentSigningAuthProvider
|
|
||||||
documentAuthOptions={envelope.authOptions}
|
|
||||||
recipient={recipient}
|
|
||||||
user={user}
|
|
||||||
>
|
|
||||||
<EnvelopeRenderProvider envelope={envelope} token={token}>
|
|
||||||
<EmbedSignDocumentV2ClientPage
|
|
||||||
hidePoweredBy={hidePoweredBy}
|
|
||||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
|
||||||
/>
|
|
||||||
</EnvelopeRenderProvider>
|
|
||||||
</DocumentSigningAuthProvider>
|
|
||||||
</EnvelopeSigningProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
181
apps/remix/app/routes/embed+/_v0+/sign.$url.tsx
Normal file
181
apps/remix/app/routes/embed+/_v0+/sign.$url.tsx
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { data } from 'react-router';
|
||||||
|
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
|
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||||
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
|
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||||
|
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||||
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
|
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||||
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
|
||||||
|
import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page';
|
||||||
|
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||||
|
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
|
import type { Route } from './+types/sign.$url';
|
||||||
|
|
||||||
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const { requestMetadata } = getOptionalLoaderContext();
|
||||||
|
|
||||||
|
if (!params.url) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = params.url;
|
||||||
|
|
||||||
|
const { user } = await getOptionalSession(request);
|
||||||
|
|
||||||
|
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||||
|
getDocumentAndSenderByToken({
|
||||||
|
token,
|
||||||
|
userId: user?.id,
|
||||||
|
requireAccessAuth: false,
|
||||||
|
}).catch(() => null),
|
||||||
|
getFieldsForToken({ token }),
|
||||||
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
|
getCompletedFieldsForToken({ token }).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// `document.directLink` is always available but we're doing this to
|
||||||
|
// satisfy the type checker.
|
||||||
|
if (!document || !recipient) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: document.teamId });
|
||||||
|
|
||||||
|
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||||
|
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||||
|
|
||||||
|
// TODO: Make this more robust, we need to ensure the owner is either
|
||||||
|
// TODO: the member of a team that has an active subscription, is an early
|
||||||
|
// TODO: adopter or is an enterprise user.
|
||||||
|
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-paywall',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
||||||
|
match(accesssAuth)
|
||||||
|
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
||||||
|
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
|
||||||
|
.exhaustive(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAccessAuthValid) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-authentication-required',
|
||||||
|
email: user?.email || recipient.email,
|
||||||
|
returnTo: `/embed/sign/${token}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
|
||||||
|
|
||||||
|
if (!isRecipientsTurnToSign) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-waiting-for-turn',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await viewedDocument({
|
||||||
|
token,
|
||||||
|
requestMetadata,
|
||||||
|
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allRecipients =
|
||||||
|
recipient.role === RecipientRole.ASSISTANT
|
||||||
|
? await getRecipientsForAssistant({
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
document,
|
||||||
|
allRecipients,
|
||||||
|
recipient,
|
||||||
|
fields,
|
||||||
|
completedFields,
|
||||||
|
hidePoweredBy,
|
||||||
|
allowEmbedSigningWhitelabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmbedSignDocumentPage() {
|
||||||
|
const {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
document,
|
||||||
|
allRecipients,
|
||||||
|
recipient,
|
||||||
|
fields,
|
||||||
|
completedFields,
|
||||||
|
hidePoweredBy,
|
||||||
|
allowEmbedSigningWhitelabel,
|
||||||
|
} = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentSigningProvider
|
||||||
|
email={recipient.email}
|
||||||
|
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
||||||
|
signature={user?.email === recipient.email ? user?.signature : undefined}
|
||||||
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||||
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||||
|
>
|
||||||
|
<DocumentSigningAuthProvider
|
||||||
|
documentAuthOptions={document.authOptions}
|
||||||
|
recipient={recipient}
|
||||||
|
user={user}
|
||||||
|
>
|
||||||
|
<EmbedSignDocumentClientPage
|
||||||
|
token={token}
|
||||||
|
documentId={document.id}
|
||||||
|
envelopeId={document.envelopeId}
|
||||||
|
documentData={document.documentData}
|
||||||
|
recipient={recipient}
|
||||||
|
fields={fields}
|
||||||
|
completedFields={completedFields}
|
||||||
|
metadata={document.documentMeta}
|
||||||
|
isCompleted={isDocumentCompleted(document.status)}
|
||||||
|
hidePoweredBy={hidePoweredBy}
|
||||||
|
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||||
|
allRecipients={allRecipients}
|
||||||
|
/>
|
||||||
|
</DocumentSigningAuthProvider>
|
||||||
|
</DocumentSigningProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -67,7 +67,6 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
export default function MultisignPage() {
|
export default function MultisignPage() {
|
||||||
const { envelopes, user, hidePoweredBy, allowWhitelabelling } =
|
const { envelopes, user, hidePoweredBy, allowWhitelabelling } =
|
||||||
useSuperLoaderData<typeof loader>();
|
useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
const revalidator = useRevalidator();
|
const revalidator = useRevalidator();
|
||||||
|
|
||||||
const [selectedDocument, setSelectedDocument] = useState<
|
const [selectedDocument, setSelectedDocument] = useState<
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { FieldType } from '@prisma/client';
|
import { FieldType } from '@prisma/client';
|
||||||
|
|
||||||
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { TFieldCheckbox } from '@documenso/lib/types/field';
|
import type { TFieldCheckbox } from '@documenso/lib/types/field';
|
||||||
import { parseCheckboxCustomText } from '@documenso/lib/utils/fields';
|
import { parseCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||||
@ -45,13 +44,6 @@ export const handleCheckboxFieldClick = async (
|
|||||||
|
|
||||||
let checkedValues: number[] | null = newValues.filter((v) => v.isChecked).map((v) => v.index);
|
let checkedValues: number[] | null = newValues.filter((v) => v.isChecked).map((v) => v.index);
|
||||||
|
|
||||||
if (checkedValues.length === 0) {
|
|
||||||
return {
|
|
||||||
type: FieldType.CHECKBOX,
|
|
||||||
value: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validationRule && validationLength) {
|
if (validationRule && validationLength) {
|
||||||
const checkboxValidationRule = checkboxValidationSigns.find(
|
const checkboxValidationRule = checkboxValidationSigns.find(
|
||||||
(sign) => sign.label === validationRule,
|
(sign) => sign.label === validationRule,
|
||||||
@ -63,34 +55,13 @@ export const handleCheckboxFieldClick = async (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom logic to make it flow better.
|
|
||||||
// If "at most" OR "exactly" 1 value then just return the new selected value if exists.
|
|
||||||
if (
|
|
||||||
(checkboxValidationRule.value === '=' || checkboxValidationRule.value === '<=') &&
|
|
||||||
validationLength === 1
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
type: FieldType.CHECKBOX,
|
|
||||||
value: [clickedCheckboxIndex],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = validateCheckboxLength(
|
|
||||||
checkedValues.length,
|
|
||||||
checkboxValidationRule.value,
|
|
||||||
validationLength,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only render validation dialog if validation is invalid.
|
|
||||||
if (!isValid) {
|
|
||||||
checkedValues = await SignFieldCheckboxDialog.call({
|
checkedValues = await SignFieldCheckboxDialog.call({
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
validationRule: checkboxValidationRule.value,
|
validationRule: checkboxValidationRule.value,
|
||||||
validationLength,
|
validationLength,
|
||||||
preselectedIndices: checkedValues,
|
preselectedIndices: currentCheckedIndices,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!checkedValues) {
|
if (!checkedValues) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
|
|||||||
|
|
||||||
type HandleNumberFieldClickOptions = {
|
type HandleNumberFieldClickOptions = {
|
||||||
field: TFieldNumber;
|
field: TFieldNumber;
|
||||||
number: string | null;
|
number: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleNumberFieldClick = async (
|
export const handleNumberFieldClick = async (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user