diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index e0b55dbf5..9f1ebb289 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -256,6 +256,7 @@ export const SinglePlayerClient = () => { fields={fields} onSubmit={onSignSubmit} requireName={Boolean(fields.find((field) => field.type === 'NAME'))} + requireCustomText={Boolean(fields.find((field) => field.type === 'TEXT'))} requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))} /> diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 99b9d1dd7..83cdb93e2 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -29,6 +29,7 @@ import { NameField } from './name-field'; import { NoLongerAvailable } from './no-longer-available'; import { SigningProvider } from './provider'; import { SignatureField } from './signature-field'; +import { TextField } from './text-field'; export type SigningPageProps = { params: { @@ -168,6 +169,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp .with(FieldType.EMAIL, () => ( )) + .with(FieldType.TEXT, () => ( + + )) .otherwise(() => null), )} diff --git a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx new file mode 100644 index 000000000..0b91fa283 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useEffect, useState, useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Loader } from 'lucide-react'; + +import type { Recipient } from '@documenso/prisma/client'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { SigningFieldContainer } from './signing-field-container'; + +export type TextFieldProps = { + field: FieldWithSignature; + recipient: Recipient; +}; + +export const TextField = ({ field, recipient }: TextFieldProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const [isPending, startTransition] = useTransition(); + + const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(); + + const { + mutateAsync: removeSignedFieldWithToken, + isLoading: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + + const [showCustomTextModal, setShowCustomTextModal] = useState(false); + const [localText, setLocalCustomText] = useState(''); + const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false); + + useEffect(() => { + if (!showCustomTextModal && !isLocalSignatureSet) { + setLocalCustomText(''); + } + }, [showCustomTextModal, isLocalSignatureSet]); + + const onSign = async () => { + try { + if (!localText) { + setIsLocalSignatureSet(false); + setShowCustomTextModal(true); + return; + } + + if (!localText) { + return; + } + + await signFieldWithToken({ + token: recipient.token, + fieldId: field.id, + value: localText, + isBase64: true, + }); + + setLocalCustomText(''); + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while signing the document.', + variant: 'destructive', + }); + } + }; + + const onRemove = async () => { + try { + await removeSignedFieldWithToken({ + token: recipient.token, + fieldId: field.id, + }); + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while removing the text.', + variant: 'destructive', + }); + } + }; + + return ( + + {isLoading && ( + + + + )} + + {!field.inserted && ( + Text + )} + + {field.inserted && {field.customText}} + + + + + Enter your Text ({recipient.email}) + + + + Custom Text + + setLocalCustomText(e.target.value)} + /> + + + + + { + setShowCustomTextModal(false); + setLocalCustomText(''); + }} + > + Cancel + + + { + setShowCustomTextModal(false); + setIsLocalSignatureSet(true); + void onSign(); + }} + > + Save Text + + + + + + + ); +}; diff --git a/packages/lib/server-only/pdf/insert-field-in-pdf.ts b/packages/lib/server-only/pdf/insert-field-in-pdf.ts index dde46ba6b..1aabf96b8 100644 --- a/packages/lib/server-only/pdf/insert-field-in-pdf.ts +++ b/packages/lib/server-only/pdf/insert-field-in-pdf.ts @@ -1,3 +1,4 @@ +// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821 import fontkit from '@pdf-lib/fontkit'; import { PDFDocument, StandardFonts } from 'pdf-lib'; @@ -73,13 +74,17 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu height: imageHeight, }); } else { - let textWidth = font.widthOfTextAtSize(field.customText, fontSize); + const longestLineInTextForWidth = field.customText + .split('\n') + .sort((a, b) => b.length - a.length)[0]; + + let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize); const textHeight = font.heightAtSize(fontSize); const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1); fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize); - textWidth = font.widthOfTextAtSize(field.customText, fontSize); + textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize); const textX = fieldX + (fieldWidth - textWidth) / 2; let textY = fieldY + (fieldHeight - textHeight) / 2; diff --git a/packages/trpc/server/singleplayer-router/helper.ts b/packages/trpc/server/singleplayer-router/helper.ts index 0ec0ba42d..32d03c0ac 100644 --- a/packages/trpc/server/singleplayer-router/helper.ts +++ b/packages/trpc/server/singleplayer-router/helper.ts @@ -22,6 +22,7 @@ export const mapField = ( .with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a')) .with(FieldType.EMAIL, () => signer.email) .with(FieldType.NAME, () => signer.name) + .with(FieldType.TEXT, () => signer.customText) .otherwise(() => ''); return { diff --git a/packages/trpc/server/singleplayer-router/schema.ts b/packages/trpc/server/singleplayer-router/schema.ts index 9fa56e7b1..412429fca 100644 --- a/packages/trpc/server/singleplayer-router/schema.ts +++ b/packages/trpc/server/singleplayer-router/schema.ts @@ -12,6 +12,7 @@ export const ZCreateSinglePlayerDocumentMutationSchema = z.object({ email: z.string().email().min(1), name: z.string(), signature: z.string(), + customText: z.string(), }), fields: z.array( z.object({ diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index a6266d1aa..f5b839158 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -552,6 +552,28 @@ export const AddFieldsFormPartial = ({ + + setSelectedField(FieldType.TEXT)} + onMouseDown={() => setSelectedField(FieldType.TEXT)} + data-selected={selectedField === FieldType.TEXT ? true : undefined} + > + + + + {'Text'} + + + Custom Text + + + diff --git a/packages/ui/primitives/document-flow/add-signature.tsx b/packages/ui/primitives/document-flow/add-signature.tsx index 5accdca16..f1ebc885e 100644 --- a/packages/ui/primitives/document-flow/add-signature.tsx +++ b/packages/ui/primitives/document-flow/add-signature.tsx @@ -44,6 +44,7 @@ export type AddSignatureFormProps = { onSubmit: (_data: TAddSignatureFormSchema) => Promise | void; requireName?: boolean; + requireCustomText?: boolean; requireSignature?: boolean; }; @@ -54,6 +55,7 @@ export const AddSignatureFormPartial = ({ onSubmit, requireName = false, + requireCustomText = false, requireSignature = true, }: AddSignatureFormProps) => { const { currentStep, totalSteps } = useStep(); @@ -70,6 +72,14 @@ export const AddSignatureFormPartial = ({ }); } + if (requireCustomText && val.customText.length === 0) { + ctx.addIssue({ + path: ['customText'], + code: 'custom', + message: 'Text is required', + }); + } + if (requireSignature && val.signature.length === 0) { ctx.addIssue({ path: ['signature'], @@ -85,6 +95,7 @@ export const AddSignatureFormPartial = ({ name: '', email: '', signature: '', + customText: '', }, }); @@ -131,6 +142,11 @@ export const AddSignatureFormPartial = ({ return !form.formState.errors.email; } + if (fieldType === FieldType.TEXT) { + await form.trigger('customText'); + return !form.formState.errors.customText; + } + return true; }; @@ -154,6 +170,11 @@ export const AddSignatureFormPartial = ({ customText: form.getValues('name'), inserted: true, })) + .with(FieldType.TEXT, () => ({ + ...field, + customText: form.getValues('customText'), + inserted: true, + })) .with(FieldType.SIGNATURE, () => { const value = form.getValues('signature'); @@ -302,6 +323,29 @@ export const AddSignatureFormPartial = ({ )} /> )} + + {requireCustomText && ( + ( + + Custom Text + + { + onFormValueChange(FieldType.TEXT); + field.onChange(value); + }} + /> + + + + )} + /> + )} @@ -330,7 +374,7 @@ export const AddSignatureFormPartial = ({ {localFields.map((field) => match(field.type) - .with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, () => { + .with(FieldType.DATE, FieldType.TEXT, FieldType.EMAIL, FieldType.NAME, () => { return ( )} - + diff --git a/packages/ui/primitives/document-flow/single-player-mode-fields.tsx b/packages/ui/primitives/document-flow/single-player-mode-fields.tsx index 7cecd7131..2ef115e4b 100644 --- a/packages/ui/primitives/document-flow/single-player-mode-fields.tsx +++ b/packages/ui/primitives/document-flow/single-player-mode-fields.tsx @@ -172,6 +172,7 @@ export function SinglePlayerModeCustomTextField({ .with(FieldType.DATE, () => 'Date') .with(FieldType.NAME, () => 'Name') .with(FieldType.EMAIL, () => 'Email') + .with(FieldType.TEXT, () => 'Text') .with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, () => 'Signature') .otherwise(() => '')} diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx index 602bd749b..b09d740f5 100644 --- a/packages/ui/primitives/template-flow/add-template-fields.tsx +++ b/packages/ui/primitives/template-flow/add-template-fields.tsx @@ -528,6 +528,28 @@ export const AddTemplateFieldsFormPartial = ({ + + setSelectedField(FieldType.TEXT)} + onMouseDown={() => setSelectedField(FieldType.TEXT)} + data-selected={selectedField === FieldType.TEXT ? true : undefined} + > + + + + {'Text'} + + + Custom Text + + +
Text
{field.customText}
+ {'Text'} +
Custom Text