diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index 81ec5bdf4..21e788ffa 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -19,13 +19,15 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { useCurrentTeam } from '~/providers/team'; type DocumentDuplicateDialogProps = { - id: number; + id: string; + token?: string; open: boolean; onOpenChange: (_open: boolean) => void; }; export const DocumentDuplicateDialog = ({ id, + token, open, onOpenChange, }: DocumentDuplicateDialogProps) => { @@ -36,27 +38,23 @@ export const DocumentDuplicateDialog = ({ const team = useCurrentTeam(); - const { data: document, isLoading } = trpcReact.document.get.useQuery( - { - documentId: id, - }, - { - queryHash: `document-duplicate-dialog-${id}`, - enabled: open === true, - }, - ); + const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } = + trpcReact.envelope.item.getManyByToken.useQuery( + { + envelopeId: id, + access: token ? { type: 'recipient', token } : { type: 'user' }, + }, + { + enabled: open, + }, + ); - const documentData = document?.documentData - ? { - ...document.documentData, - data: document.documentData.initialData, - } - : undefined; + const envelopeItems = envelopeItemsPayload?.data || []; const documentsPath = formatDocumentsPath(team.url); - const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } = - trpcReact.document.duplicate.useMutation({ + const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = + trpcReact.envelope.duplicate.useMutation({ onSuccess: async ({ id }) => { toast({ title: _(msg`Document Duplicated`), @@ -71,7 +69,7 @@ export const DocumentDuplicateDialog = ({ const onDuplicate = async () => { try { - await duplicateDocument({ documentId: id }); + await duplicateEnvelope({ envelopeId: id }); } catch { toast({ title: _(msg`Something went wrong`), @@ -83,14 +81,14 @@ export const DocumentDuplicateDialog = ({ }; return ( - !isLoading && onOpenChange(value)}> + !isDuplicating && onOpenChange(value)}> Duplicate - {!documentData || isLoading ? ( + {isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (

Loading Document... @@ -98,7 +96,12 @@ export const DocumentDuplicateDialog = ({

) : (
- +
)} @@ -115,8 +118,8 @@ export const DocumentDuplicateDialog = ({ +
+ {/* Footer of left sidebar. */} + {!isEmbed && ( +
+ +
+ )}
-
+
{/* Horizontal envelope item selector */} {envelopeItems.length > 1 && ( @@ -202,11 +226,11 @@ export const DocumentSigningPageViewV2 = () => { )} {/* Document View */} -
+
{currentEnvelopeItem ? ( ) : ( @@ -218,9 +242,20 @@ export const DocumentSigningPageViewV2 = () => { )} {/* Mobile widget - Additional padding to allow users to scroll */} -
+
+ + {!hidePoweredBy && ( + + Powered by + + + )}
diff --git a/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx b/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx index aaecd3ee9..ddaf3c355 100644 --- a/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx +++ b/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx @@ -13,6 +13,7 @@ import { prop, sortBy } from 'remeda'; import { isBase64Image } from '@documenso/lib/constants/signatures'; 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 { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { isFieldUnsignedAndRequired, isRequiredField, @@ -51,7 +52,11 @@ export type EnvelopeSigningContextValue = { setSelectedAssistantRecipientId: (_value: number | null) => void; selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null; - signField: (_fieldId: number, _value: TSignEnvelopeFieldValue) => Promise; + signField: ( + _fieldId: number, + _value: TSignEnvelopeFieldValue, + authOptions?: TRecipientActionAuth, + ) => Promise>; }; const EnvelopeSigningContext = createContext(null); @@ -284,19 +289,26 @@ export const EnvelopeSigningProvider = ({ : null; }, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]); - const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => { + const signField = async ( + fieldId: number, + fieldValue: TSignEnvelopeFieldValue, + authOptions?: TRecipientActionAuth, + ) => { // Set the field locally for direct templates. if (isDirectTemplate) { - handleDirectTemplateFieldInsertion(fieldId, fieldValue); - return; + const signedField = handleDirectTemplateFieldInsertion(fieldId, fieldValue); + + return signedField; } - await signEnvelopeField({ + const { signedField } = await signEnvelopeField({ token: envelopeData.recipient.token, fieldId, fieldValue, - authOptions: undefined, + authOptions, }); + + return signedField; }; const handleDirectTemplateFieldInsertion = ( @@ -354,6 +366,8 @@ export const EnvelopeSigningProvider = ({ fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)), }, })); + + return updatedField; }; return ( diff --git a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx index b4360d6bb..d5ff98f75 100644 --- a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx +++ b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; import { Trans } from '@lingui/react/macro'; -import type { DocumentData, EnvelopeItem } from '@prisma/client'; +import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client'; +import { DownloadIcon } from 'lucide-react'; import { DateTime } from 'luxon'; import { @@ -22,9 +23,10 @@ import { } from '@documenso/ui/primitives/dialog'; 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 EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer'; -import { ShareDocumentDownloadButton } from '../share-document-download-button'; export type DocumentCertificateQRViewProps = { documentId: number; @@ -34,6 +36,7 @@ export type DocumentCertificateQRViewProps = { documentTeamUrl: string; recipientCount?: number; completedDate?: Date; + token: string; }; export const DocumentCertificateQRView = ({ @@ -44,6 +47,7 @@ export const DocumentCertificateQRView = ({ documentTeamUrl, recipientCount = 0, completedDate, + token, }: DocumentCertificateQRViewProps) => { const { data: documentViaUser } = trpc.document.get.useQuery({ documentId, @@ -96,11 +100,12 @@ export const DocumentCertificateQRView = ({ )} {internalVersion === 2 ? ( - + ) : ( @@ -119,14 +124,27 @@ export const DocumentCertificateQRView = ({
- + + Download + + } />
- +
)} @@ -138,14 +156,16 @@ type DocumentCertificateQrV2Props = { title: string; recipientCount: number; formattedDate: string; + token: string; }; const DocumentCertificateQrV2 = ({ title, recipientCount, formattedDate, + token, }: DocumentCertificateQrV2Props) => { - const { currentEnvelopeItem } = useCurrentEnvelopeRender(); + const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender(); return (
@@ -163,18 +183,24 @@ const DocumentCertificateQrV2 = ({
- {currentEnvelopeItem && ( - - )} + + + Download + + } + />
- +
); diff --git a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx b/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx index 01cee5ad3..2dbe7e59c 100644 --- a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx +++ b/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx @@ -16,9 +16,9 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/l import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; -import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { 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 { useToast } from '@documenso/ui/primitives/use-toast'; @@ -62,14 +62,18 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon try { setIsLoading(true); - const response = await putPdfFile(file); - - const { legacyDocumentId: id } = await createDocument({ + const payload = { title: file.name, - documentDataId: response.id, - timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field. + 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(); diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index 0b731fa35..e31224f70 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -441,9 +441,10 @@ export const DocumentEditForm = ({ > setIsDocumentPdfLoaded(true)} /> diff --git a/apps/remix/app/components/general/document/document-page-view-button.tsx b/apps/remix/app/components/general/document/document-page-view-button.tsx index 361109580..e3e7e0afc 100644 --- a/apps/remix/app/components/general/document/document-page-view-button.tsx +++ b/apps/remix/app/components/general/document/document-page-view-button.tsx @@ -1,18 +1,14 @@ -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react'; import { Link } from 'react-router'; import { match } from 'ts-pattern'; -import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { useSession } from '@documenso/lib/client-only/providers/session'; import type { TEnvelope } from '@documenso/lib/types/envelope'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { Button } from '@documenso/ui/primitives/button'; -import { useToast } from '@documenso/ui/primitives/use-toast'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; @@ -23,9 +19,6 @@ export type DocumentPageViewButtonProps = { export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => { const { user } = useSession(); - const { toast } = useToast(); - const { _ } = useLingui(); - const recipient = envelope.recipients.find((recipient) => recipient.email === user.email); const isRecipient = !!recipient; @@ -37,25 +30,6 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps const documentsPath = formatDocumentsPath(envelope.team.url); 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({ isRecipient, isPending, @@ -95,7 +69,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps )) - .with({ isComplete: true, internalVersion: 2 }, () => ( + .with({ isComplete: true }, () => ( )) - .with({ isComplete: true }, () => ( - - )) .otherwise(() => null); }; diff --git a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx index c5b6c9371..282740369 100644 --- a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx +++ b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx @@ -1,6 +1,5 @@ import { useState } from 'react'; -import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus } from '@prisma/client'; @@ -16,13 +15,11 @@ import { } from 'lucide-react'; 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 type { TEnvelope } from '@documenso/lib/types/envelope'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; 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 { DropdownMenu, @@ -67,64 +64,6 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP 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'); return ( @@ -147,36 +86,20 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP )} - {envelope.internalVersion === 2 ? ( - e.preventDefault()}> -
- - Download -
- - } - /> - ) : ( - <> - {isComplete && ( - + e.preventDefault()}> +
Download - - )} - - - - Download Original +
- - )} + } + /> @@ -250,7 +173,8 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP {isDuplicateDialogOpen && ( diff --git a/apps/remix/app/components/general/document/document-upload-button.tsx b/apps/remix/app/components/general/document/document-upload-button.tsx index 7e092363e..ea95bae23 100644 --- a/apps/remix/app/components/general/document/document-upload-button.tsx +++ b/apps/remix/app/components/general/document/document-upload-button.tsx @@ -13,9 +13,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; 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 { 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 { DocumentDropzone } from '@documenso/ui/primitives/document-upload'; import { @@ -73,14 +73,18 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) = try { setIsLoading(true); - const response = await putPdfFile(file); - - const { legacyDocumentId: id } = await createDocument({ + const payload = { title: file.name, - documentDataId: response.id, 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(); diff --git a/apps/remix/app/components/general/document/envelope-upload-button.tsx b/apps/remix/app/components/general/document/envelope-upload-button.tsx index 645c3845f..218235706 100644 --- a/apps/remix/app/components/general/document/envelope-upload-button.tsx +++ b/apps/remix/app/components/general/document/envelope-upload-button.tsx @@ -14,9 +14,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; 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 { 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 { DocumentDropzone } from '@documenso/ui/primitives/document-upload'; import { @@ -78,35 +78,24 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo try { setIsLoading(true); - 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({ + const payload = { folderId, type, title: files[0].name, - items: envelopeItemsToCreate, meta: { timezone: userTimezone, }, - }).catch((error) => { + } satisfies TCreateEnvelopePayload; + + const formData = new FormData(); + + formData.append('payload', JSON.stringify(payload)); + + for (const file of files) { + formData.append('files', file); + } + + const { id } = await createEnvelope(formData).catch((error) => { console.error(error); throw error; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx index c75fb52a5..5c26e5a5d 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx @@ -26,7 +26,7 @@ import { fieldButtonList } from './envelope-editor-fields-drag-drop'; export default function EnvelopeEditorFieldsPageRenderer() { const { t, i18n } = useLingui(); const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor(); - const { currentEnvelopeItem } = useCurrentEnvelopeRender(); + const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); const interactiveTransformer = useRef(null); @@ -103,7 +103,6 @@ export default function EnvelopeEditorFieldsPageRenderer() { fieldUpdates.height = fieldPageHeight; } - // Todo: envelopes Use id editorFields.updateFieldByFormId(fieldFormId, fieldUpdates); // Select the field if it is not already selected. @@ -114,7 +113,7 @@ export default function EnvelopeEditorFieldsPageRenderer() { pageLayer.current?.batchDraw(); }; - const renderFieldOnLayer = (field: TLocalField) => { + const unsafeRenderFieldOnLayer = (field: TLocalField) => { if (!pageLayer.current) { return; } @@ -160,6 +159,15 @@ export default function EnvelopeEditorFieldsPageRenderer() { 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. */ diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index 4bd0915da..ceb8e072a 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -27,7 +27,8 @@ import type { import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; -import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { Separator } from '@documenso/ui/primitives/separator'; @@ -112,9 +113,34 @@ export const EnvelopeEditorFieldsPage = () => { {/* Document View */} -
+
+ {envelope.recipients.length === 0 && ( + +
+ + Missing Recipients + + + You need at least one recipient to add fields + +
+ + +
+ )} + {currentEnvelopeItem !== null ? ( - + ) : (
@@ -130,7 +156,7 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Right Section - Form Fields Panel */} - {currentEnvelopeItem && ( + {currentEnvelopeItem && envelope.recipients.length > 0 && (
{/* Recipient selector section. */}
@@ -138,29 +164,15 @@ export const EnvelopeEditorFieldsPage = () => { Selected Recipient - {envelope.recipients.length === 0 ? ( - - - You need at least one recipient to add fields - - -

- Click here to add a recipient -

- -
-
- ) : ( - - editorFields.setSelectedRecipient(recipient.id) - } - recipients={envelope.recipients} - className="w-full" - align="end" - /> - )} + + editorFields.setSelectedRecipient(recipient.id) + } + recipients={envelope.recipients} + className="w-full" + align="end" + /> {editorFields.selectedRecipient && !canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && ( diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx index e57740cd1..1232573ac 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx @@ -1,10 +1,20 @@ -import { lazy, useEffect, useState } from 'react'; +import { lazy, useEffect, useMemo, useState } from 'react'; +import { faker } from '@faker-js/faker/locale/en'; import { Trans } from '@lingui/react/macro'; -import { ConstructionIcon, FileTextIcon } from 'lucide-react'; +import { FieldType } from '@prisma/client'; +import { FileTextIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; -import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { + EnvelopeRenderProvider, + useCurrentEnvelopeRender, +} from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; +import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing'; +import { toCheckboxCustomText } from '@documenso/lib/utils/fields'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; @@ -15,15 +25,169 @@ import { EnvelopeRendererFileSelector } from './envelope-file-selector'; const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer')); +// Todo: Envelopes - Dynamically import faker export const EnvelopeEditorPreviewPage = () => { const { envelope, editorFields } = useCurrentEnvelopeEditor(); - const { currentEnvelopeItem } = useCurrentEnvelopeRender(); + const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender(); const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>( 'recipient', ); + const fieldsWithPlaceholders = useMemo(() => { + return fields.map((field) => { + const fieldMeta = ZFieldAndMetaSchema.parse(field); + + const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId); + + if (!recipient) { + throw new Error('Recipient not found'); + } + + faker.seed(recipient.id); + + const recipientName = recipient.name || faker.person.fullName(); + const recipientEmail = recipient.email || faker.internet.email(); + + faker.seed(recipient.id + field.id); + + return { + ...field, + inserted: true, + ...match(fieldMeta) + .with({ type: FieldType.TEXT }, ({ fieldMeta }) => { + let text = fieldMeta?.text || faker.lorem.words(5); + + if (fieldMeta?.characterLimit) { + text = text.slice(0, fieldMeta?.characterLimit); + } + + return { + customText: text, + }; + }) + .with({ type: FieldType.NUMBER }, ({ fieldMeta }) => { + let number = fieldMeta?.value ?? ''; + + if (number === '') { + number = faker.number + .int({ + min: fieldMeta?.minValue ?? 0, + max: fieldMeta?.maxValue ?? 1000, + }) + .toString(); + } + + return { + customText: number, + }; + }) + .with({ type: FieldType.DATE }, () => { + const date = extractFieldInsertionValues({ + fieldValue: { + type: FieldType.DATE, + value: true, + }, + field, + documentMeta: envelope.documentMeta, + }); + + return { + customText: date.customText, + }; + }) + .with({ type: FieldType.EMAIL }, () => { + return { + customText: recipientEmail, + }; + }) + .with({ type: FieldType.NAME }, () => { + return { + customText: recipientName, + }; + }) + .with({ type: FieldType.INITIALS }, () => { + return { + customText: extractInitials(recipientName), + }; + }) + .with({ type: FieldType.RADIO }, ({ fieldMeta }) => { + const values = fieldMeta?.values ?? []; + + if (values.length === 0) { + return ''; + } + + let customText = ''; + + const preselectedValue = values.findIndex((value) => value.checked); + + if (preselectedValue !== -1) { + customText = preselectedValue.toString(); + } else { + const randomIndex = faker.number.int({ min: 0, max: values.length - 1 }); + customText = randomIndex.toString(); + } + + return { + customText, + }; + }) + .with({ type: FieldType.CHECKBOX }, ({ fieldMeta }) => { + let checkedValues: number[] = []; + + const values = fieldMeta?.values ?? []; + + values.forEach((value, index) => { + if (value.checked) { + checkedValues.push(index); + } + }); + + if (checkedValues.length === 0 && values.length > 0) { + const numberOfValues = fieldMeta?.validationLength || 1; + + checkedValues = Array.from({ length: numberOfValues }, (_, index) => index); + } + + return { + customText: toCheckboxCustomText(checkedValues), + }; + }) + .with({ type: FieldType.DROPDOWN }, ({ fieldMeta }) => { + const values = fieldMeta?.values ?? []; + + let customText = fieldMeta?.defaultValue || ''; + + if (!customText && values.length > 0) { + const randomIndex = faker.number.int({ min: 0, max: values.length - 1 }); + customText = values[randomIndex].value; + } + + return { + customText, + }; + }) + .with({ type: FieldType.SIGNATURE }, () => { + return { + customText: '', + signature: { + signatureImageAsBase64: '', + typedSignature: recipientName, + }, + }; + }) + .with({ type: FieldType.FREE_SIGNATURE }, () => { + return { + customText: '', + }; + }) + .exhaustive(), + }; + }); + }, [fields, envelope, envelope.recipients, envelope.documentMeta]); + /** * Set the selected recipient to the first recipient in the envelope. */ @@ -31,40 +195,38 @@ export const EnvelopeEditorPreviewPage = () => { editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null); }, []); + // Override the parent renderer provider so we can inject custom fields. return ( -
-
- {/* Horizontal envelope item selector */} - + +
+
+ {/* Horizontal envelope item selector */} + - {/* Document View */} -
- - - Preview Mode - - - Preview what the signed document will look like with placeholder data - - + {/* Document View */} +
+ + + Preview Mode + + + Preview what the signed document will look like with placeholder data + + - {/* Coming soon section */} -
-
- -

- Coming soon -

-

- This feature is coming soon -

-
-
- - {/* Todo: Envelopes - Remove div after preview mode is implemented */} -
{currentEnvelopeItem !== null ? ( - + ) : (
@@ -78,27 +240,28 @@ export const EnvelopeEditorPreviewPage = () => { )}
-
- {/* Right Section - Form Fields Panel */} - {currentEnvelopeItem && false && ( -
- {/* Add fields section. */} -
- {/*

+ {/* Right Section - Form Fields Panel */} + {currentEnvelopeItem && false && ( +
+ {/* Add fields section. */} +
+ {/*

Preivew Mode

*/} - - - Preview Mode - - - Preview what the signed document will look like with placeholder data - - + + + Preview Mode + + + + Preview what the signed document will look like with placeholder data + + + - {/* + {/* {
Preview what a recipient will see
Preview the signed document
*/} -
+

- {false && ( - - {selectedPreviewMode === 'recipient' && ( - <> - + {false && ( + + {selectedPreviewMode === 'recipient' && ( + <> + - {/* Recipient selector section. */} -
-

- Selected Recipient -

+ {/* Recipient selector section. */} +
+

+ Selected Recipient +

- - editorFields.setSelectedRecipient(recipient.id) - } - recipients={envelope.recipients} - className="w-full" - align="end" - /> -
- - )} - - )} -
- )} -
+ + editorFields.setSelectedRecipient(recipient.id) + } + recipients={envelope.recipients} + className="w-full" + align="end" + /> +
+ + )} + + )} +
+ )} +
+ ); }; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx index ef8a1288c..375524e68 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -482,30 +482,46 @@ export const EnvelopeEditorRecipientForm = () => { const { data } = validatedFormValues; + // Weird edge case where the whole envelope is created via API + // with no signing order. If they come to this page it will show an error + // since they aren't equal and the recipient is no longer editable. + const envelopeRecipients = data.signers.map((recipient) => { + if (!canRecipientBeModified(recipient.id)) { + return { + ...recipient, + signingOrder: recipient.signingOrder, + }; + } + return recipient; + }); + const hasSigningOrderChanged = envelope.documentMeta.signingOrder !== data.signingOrder; const hasAllowDictateNextSignerChanged = envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner; const hasSignersChanged = - data.signers.length !== recipients.length || - data.signers.some((signer) => { + envelopeRecipients.length !== recipients.length || + envelopeRecipients.some((signer) => { const recipient = recipients.find((recipient) => recipient.id === signer.id); if (!recipient) { return true; } + const signerActionAuth = signer.actionAuth; + const recipientActionAuth = recipient.authOptions?.actionAuth || []; + return ( signer.email !== recipient.email || signer.name !== recipient.name || signer.role !== recipient.role || signer.signingOrder !== recipient.signingOrder || - !isDeepEqual(signer.actionAuth, recipient.authOptions?.actionAuth) + !isDeepEqual(signerActionAuth, recipientActionAuth) ); }); if (hasSignersChanged) { - setRecipientsDebounced(validatedFormValues.data.signers); + setRecipientsDebounced(envelopeRecipients); } if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) { diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx index 0c505d55c..6593609f5 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx @@ -174,7 +174,7 @@ export const EnvelopeEditorSettingsDialog = ({ const { t, i18n } = useLingui(); const { toast } = useToast(); - const { envelope } = useCurrentEnvelopeEditor(); + const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor(); const team = useCurrentTeam(); const organisation = useCurrentOrganisation(); @@ -186,14 +186,12 @@ export const EnvelopeEditorSettingsDialog = ({ documentAuth: envelope.authOptions, }); - const form = useForm({ - resolver: zodResolver(ZAddSettingsFormSchema), - defaultValues: { - externalId: envelope.externalId || '', // Todo: String or undefined? + const createDefaultValues = () => { + return { + externalId: envelope.externalId || '', visibility: envelope.visibility || '', globalAccessAuth: documentAuthOption?.globalAccessAuth || [], globalActionAuth: documentAuthOption?.globalActionAuth || [], - meta: { subject: envelope.documentMeta.subject ?? '', message: envelope.documentMeta.message ?? '', @@ -210,10 +208,13 @@ export const EnvelopeEditorSettingsDialog = ({ emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings), signatureTypes: extractTeamSignatureSettings(envelope.documentMeta), }, - }, - }); + }; + }; - const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation(); + const form = useForm({ + resolver: zodResolver(ZAddSettingsFormSchema), + defaultValues: createDefaultValues(), + }); const envelopeHasBeenSent = envelope.type === EnvelopeType.DOCUMENT && @@ -229,7 +230,6 @@ export const EnvelopeEditorSettingsDialog = ({ const emails = emailData?.data || []; - // Todo: Envelopes this doesn't make sense (look at previous) const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility); const onFormSubmit = async (data: TAddSettingsFormSchema) => { @@ -240,9 +240,7 @@ export const EnvelopeEditorSettingsDialog = ({ .safeParse(data.globalAccessAuth); try { - await updateEnvelope({ - envelopeId: envelope.id, - envelopeType: envelope.type, + await updateEnvelopeAsync({ data: { externalId: data.externalId || null, visibility: data.visibility, @@ -297,7 +295,7 @@ export const EnvelopeEditorSettingsDialog = ({ ]); useEffect(() => { - form.reset(); + form.reset(createDefaultValues()); setActiveTab('general'); }, [open, form]); @@ -323,7 +321,7 @@ export const EnvelopeEditorSettingsDialog = ({ {/* Sidebar. */} -
+
Document Settings diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx index fa19bb6a1..c13ca0d66 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx @@ -18,9 +18,9 @@ import { import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { nanoid } from '@documenso/lib/universal/id'; -import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; 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 { Card, @@ -67,8 +67,8 @@ export const EnvelopeEditorUploadPage = () => { const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } = trpc.envelope.item.createMany.useMutation({ - onSuccess: (data) => { - const createdEnvelopes = data.createdEnvelopeItems.filter( + onSuccess: ({ data }) => { + const createdEnvelopes = data.filter( (item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id), ); @@ -79,10 +79,10 @@ export const EnvelopeEditorUploadPage = () => { }); const { mutateAsync: updateEnvelopeItems } = trpc.envelope.item.updateMany.useMutation({ - onSuccess: (data) => { + onSuccess: ({ data }) => { setLocalEnvelope({ envelopeItems: envelope.envelopeItems.map((originalItem) => { - const updatedItem = data.updatedEnvelopeItems.find((item) => item.id === originalItem.id); + const updatedItem = data.find((item) => item.id === originalItem.id); if (updatedItem) { return { @@ -114,36 +114,19 @@ export const EnvelopeEditorUploadPage = () => { setLocalFiles((prev) => [...prev, ...newUploadingFiles]); - const result = await Promise.all( - files.map(async (file, index) => { - try { - const response = await putPdfFile(file); - - // Mark as uploaded (remove from uploading state) - return { - title: file.name, - documentDataId: response.id, - }; - } catch (_error) { - setLocalFiles((prev) => - prev.map((uploadingFile) => - uploadingFile.id === newUploadingFiles[index].id - ? { ...uploadingFile, isError: true, isUploading: false } - : uploadingFile, - ), - ); - } - }), - ); - - const envelopeItemsToCreate = result.filter( - (item): item is { title: string; documentDataId: string } => item !== undefined, - ); - - const { createdEnvelopeItems } = await createEnvelopeItems({ + const payload = { envelopeId: envelope.id, - items: envelopeItemsToCreate, - }).catch((error) => { + } satisfies TCreateEnvelopeItemsPayload; + + const formData = new FormData(); + + formData.append('payload', JSON.stringify(payload)); + + for (const file of files) { + formData.append('files', file); + } + + const { data } = await createEnvelopeItems(formData).catch((error) => { console.error(error); // Set error state on files in batch upload. @@ -165,7 +148,7 @@ export const EnvelopeEditorUploadPage = () => { ); return filteredFiles.concat( - createdEnvelopeItems.map((item) => ({ + data.map((item) => ({ id: item.id, envelopeItemId: item.id, title: item.title, @@ -203,7 +186,6 @@ export const EnvelopeEditorUploadPage = () => { debouncedUpdateEnvelopeItems(items); }; - // Todo: Envelopes - Sync into envelopes data const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => { void updateEnvelopeItems({ envelopeId: envelope.id, diff --git a/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx b/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx index 1d570bed7..144a2bc41 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx @@ -29,7 +29,7 @@ export const EnvelopeItemSelector = ({ {...buttonProps} >
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx index f6ae5c5d7..48f13127e 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo } from 'react'; import { useLingui } from '@lingui/react/macro'; +import { type Recipient, SigningStatus } from '@prisma/client'; import type Konva from 'konva'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; @@ -8,11 +9,23 @@ import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/e import type { TEnvelope } from '@documenso/lib/types/envelope'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; +import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip'; + +type GenericLocalField = TEnvelope['fields'][number] & { + recipient: Pick; +}; export default function EnvelopeGenericPageRenderer() { const { i18n } = useLingui(); - const { currentEnvelopeItem, fields, getRecipientColorKey } = useCurrentEnvelopeRender(); + const { + currentEnvelopeItem, + fields, + recipients, + getRecipientColorKey, + setRenderError, + overrideSettings, + } = useCurrentEnvelopeRender(); const { stage, @@ -28,44 +41,73 @@ export default function EnvelopeGenericPageRenderer() { const { _className, scale } = pageContext; - const localPageFields = useMemo( - () => - fields.filter( + const localPageFields = useMemo((): GenericLocalField[] => { + return fields + .filter( (field) => field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, - ), - [fields, pageContext.pageNumber], - ); + ) + .map((field) => { + const recipient = recipients.find((recipient) => recipient.id === field.recipientId); - const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => { + if (!recipient) { + throw new Error(`Recipient not found for field ${field.id}`); + } + + return { + ...field, + recipient, + }; + }); + }, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]); + + const unsafeRenderFieldOnLayer = (field: GenericLocalField) => { if (!pageLayer.current) { console.error('Layer not loaded yet'); return; } + const { recipient } = field; + + const fieldTranslations = getClientSideFieldTranslations(i18n); + + const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted; + renderField({ scale, pageLayer: pageLayer.current, field: { renderId: field.id.toString(), ...field, - customText: '', width: Number(field.width), height: Number(field.height), positionX: Number(field.positionX), positionY: Number(field.positionY), - inserted: false, + customText: isInserted ? field.customText : '', fieldMeta: field.fieldMeta, + signature: { + signatureImageAsBase64: '', + typedSignature: fieldTranslations.SIGNATURE, + }, }, - translations: getClientSideFieldTranslations(i18n), + translations: fieldTranslations, pageWidth: unscaledViewport.width, pageHeight: unscaledViewport.height, color: getRecipientColorKey(field.recipientId), editable: false, - mode: 'sign', + mode: overrideSettings?.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. */ @@ -113,6 +155,16 @@ export default function EnvelopeGenericPageRenderer() { className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`} > + {overrideSettings?.showRecipientTooltip && + localPageFields.map((field) => ( + + ))} + {/* The element Konva will inject it's canvas into. */}
diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx index 98a9173ee..e010e5821 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx @@ -8,6 +8,8 @@ import { Label } from '@documenso/ui/primitives/label'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; +import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; + import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; export default function EnvelopeSignerForm() { @@ -25,6 +27,8 @@ export default function EnvelopeSignerForm() { setSelectedAssistantRecipientId, } = useRequiredEnvelopeSigningContext(); + const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {}; + const hasSignatureField = useMemo(() => { return recipientFields.some((field) => field.type === FieldType.SIGNATURE); }, [recipientFields]); @@ -37,7 +41,7 @@ export default function EnvelopeSignerForm() { if (recipient.role === RecipientRole.ASSISTANT) { return ( -
+
setFullName(e.target.value.trimStart())} + disabled={isNameLocked} + onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())} />
diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx index b48ee0c55..bf7b2a971 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx @@ -16,6 +16,7 @@ import { import { Separator } from '@documenso/ui/primitives/separator'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; +import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogoIcon } from '../branding-logo-icon'; @@ -28,7 +29,7 @@ export const EnvelopeSignerHeader = () => { useRequiredEnvelopeSigningContext(); return ( -