diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 52d0d5de8..907f74698 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -1,11 +1,11 @@ { "name": "@documenso/marketing", - "version": "1.7.2-rc.0", + "version": "1.7.2-rc.1", "private": true, "license": "AGPL-3.0", "scripts": { "dev": "next dev -p 3001", - "build": "turbo run translate:extract && turbo run translate:compile && next build", + "build": "npm run translate:extract --prefix ../../ && turbo run translate:compile && next build", "start": "next start -p 3001", "lint": "next lint", "lint:fix": "next lint --fix", diff --git a/apps/web/package.json b/apps/web/package.json index 235755674..314f39723 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,11 +1,11 @@ { "name": "@documenso/web", - "version": "1.7.2-rc.0", + "version": "1.7.2-rc.1", "private": true, "license": "AGPL-3.0", "scripts": { "dev": "next dev -p 3000", - "build": "turbo run translate:extract && turbo run translate:compile && next build", + "build": "npm run translate:extract --prefix ../../ && turbo run translate:compile && next build", "start": "next start", "lint": "next lint", "e2e:prepare": "next build && next start", diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 9c37ab7b7..571ca535f 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -112,6 +112,24 @@ export const EditDocumentForm = ({ }, }); + const { mutateAsync: updateTypedSignature } = + trpc.document.updateTypedSignatureSettings.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (newData) => { + utils.document.getDocumentWithDetailsById.setData( + { + id: initialDocument.id, + teamId: team?.id, + }, + (oldData) => ({ + ...(oldData || initialDocument), + ...newData, + id: Number(newData.id), + }), + ); + }, + }); + const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: (newRecipients) => { @@ -258,6 +276,11 @@ export const EditDocumentForm = ({ fields: data.fields, }); + await updateTypedSignature({ + documentId: document.id, + typedSignatureEnabled: data.typedSignatureEnabled, + }); + // Clear all field data from localStorage for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); @@ -387,6 +410,7 @@ export const EditDocumentForm = ({ fields={fields} onSubmit={onAddFieldsFormSubmit} isDocumentPdfLoaded={isDocumentPdfLoaded} + typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled} teamId={team?.id} /> diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx index 3b2a384be..0181a5ea7 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -144,6 +144,7 @@ export const TemplatesDataTable = ({
diff --git a/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx b/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx index 2a5a92992..90cd2c5d4 100644 --- a/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx @@ -434,12 +434,14 @@ export const TemplateDirectLinkDialog = ({ diff --git a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx index f43b91ffe..cbf5b5e4a 100644 --- a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx @@ -15,7 +15,9 @@ import { } from '@documenso/lib/constants/template'; import { AppError } from '@documenso/lib/errors/app-error'; import type { Recipient } from '@documenso/prisma/client'; +import { DocumentSigningOrder } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { @@ -51,6 +53,7 @@ const ZAddRecipientsForNewDocumentSchema = z id: z.number(), email: z.string().email(), name: z.string(), + signingOrder: z.number().optional(), }), ), }) @@ -86,6 +89,7 @@ type TAddRecipientsForNewDocumentSchema = z.infer { - const isRecipientEmailPlaceholder = recipient.email.match( - TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX, - ); + recipients: recipients + .sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0)) + .map((recipient) => { + const isRecipientEmailPlaceholder = recipient.email.match( + TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX, + ); - const isRecipientNamePlaceholder = recipient.name.match( - TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX, - ); + const isRecipientNamePlaceholder = recipient.name.match( + TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX, + ); - return { - id: recipient.id, - name: !isRecipientNamePlaceholder ? recipient.name : '', - email: !isRecipientEmailPlaceholder ? recipient.email : '', - }; - }), + return { + id: recipient.id, + name: !isRecipientNamePlaceholder ? recipient.name : '', + email: !isRecipientEmailPlaceholder ? recipient.email : '', + signingOrder: recipient.signingOrder ?? undefined, + }; + }), }, }); @@ -203,6 +211,33 @@ export function UseTemplateDialog({
{formRecipients.map((recipient, index) => (
+ {templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && ( + ( + + + + + + + )} + /> + )} + { setSignature(value); }} + allowTypedSignature={document.documentMeta?.typedSignatureEnabled} /> diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx index 990dfe057..b564ea11c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -31,12 +31,12 @@ import { useRequiredSigningContext } from './provider'; import { SigningFieldContainer } from './signing-field-container'; type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text'; - export type SignatureFieldProps = { field: FieldWithSignature; recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; + typedSignatureEnabled?: boolean; }; export const SignatureField = ({ @@ -44,6 +44,7 @@ export const SignatureField = ({ recipient, onSignField, onUnsignField, + typedSignatureEnabled, }: SignatureFieldProps) => { const router = useRouter(); @@ -92,14 +93,12 @@ export const SignatureField = ({ return true; }; - /** * When the user clicks the sign button in the dialog where they enter their signature. */ const onDialogSignClick = () => { setShowSignatureModal(false); setProvidedSignature(localSignature); - if (!localSignature) { return; } @@ -109,7 +108,6 @@ export const SignatureField = ({ actionTarget: field.type, }); }; - const onSign = async (authOptions?: TRecipientActionAuth, signature?: string) => { try { const value = signature || providedSignature; @@ -231,11 +229,11 @@ export const SignatureField = ({ id="signature" className="border-border mt-2 h-44 w-full rounded-md border" onChange={(value) => setLocalSignature(value)} + allowTypedSignature={typedSignatureEnabled} />
-
- +
+ ( + + + field.onChange(checked)} + disabled={form.formState.isSubmitting} + /> + - + + Enable Typed Signatures + + + )} + /> - + + +

+ Signature +

+
+
+ - + + +

+ + Initials +

+
+
+ - + + +

+ + Email +

+
+
+ - + + +

+ + Name +

+
+
+ - + + +

+ + Date +

+
+
+ - + + +

+ + Text +

+
+
+ - + + +

+ + Number +

+
+
+ - - -
+ + +

+ + Radio +

+
+
+ + + + + + +
+
@@ -1059,8 +1093,9 @@ export const AddFieldsFormPartial = ({ { previousStep(); remove(); diff --git a/packages/ui/primitives/document-flow/add-fields.types.ts b/packages/ui/primitives/document-flow/add-fields.types.ts index 7309250a8..4d9c89e73 100644 --- a/packages/ui/primitives/document-flow/add-fields.types.ts +++ b/packages/ui/primitives/document-flow/add-fields.types.ts @@ -18,6 +18,7 @@ export const ZAddFieldsFormSchema = z.object({ fieldMeta: ZFieldMetaSchema, }), ), + typedSignatureEnabled: z.boolean(), }); export type TAddFieldsFormSchema = z.infer; diff --git a/packages/ui/primitives/signature-pad/signature-pad.tsx b/packages/ui/primitives/signature-pad/signature-pad.tsx index 6859d21ec..82e6b52b3 100644 --- a/packages/ui/primitives/signature-pad/signature-pad.tsx +++ b/packages/ui/primitives/signature-pad/signature-pad.tsx @@ -3,12 +3,15 @@ import type { HTMLAttributes, MouseEvent, PointerEvent, TouchEvent } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { Caveat } from 'next/font/google'; + import { Trans } from '@lingui/macro'; import { Undo2 } from 'lucide-react'; import type { StrokeOptions } from 'perfect-freehand'; import { getStroke } from 'perfect-freehand'; import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once'; +import { Input } from '@documenso/ui/primitives/input'; import { Select, SelectContent, @@ -21,12 +24,20 @@ import { cn } from '../../lib/utils'; import { getSvgPathFromStroke } from './helper'; import { Point } from './point'; +const fontCaveat = Caveat({ + weight: ['500'], + subsets: ['latin'], + display: 'swap', + variable: '--font-caveat', +}); + const DPI = 2; export type SignaturePadProps = Omit, 'onChange'> & { onChange?: (_signatureDataUrl: string | null) => void; containerClassName?: string; disabled?: boolean; + allowTypedSignature?: boolean; }; export const SignaturePad = ({ @@ -35,6 +46,7 @@ export const SignaturePad = ({ defaultValue, onChange, disabled = false, + allowTypedSignature, ...props }: SignaturePadProps) => { const $el = useRef(null); @@ -44,6 +56,7 @@ export const SignaturePad = ({ const [lines, setLines] = useState([]); const [currentLine, setCurrentLine] = useState([]); const [selectedColor, setSelectedColor] = useState('black'); + const [typedSignature, setTypedSignature] = useState(''); const perfectFreehandOptions = useMemo(() => { const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10; @@ -181,34 +194,107 @@ export const SignaturePad = ({ onChange?.(null); + setTypedSignature(''); setLines([]); setCurrentLine([]); }; + const renderTypedSignature = () => { + if ($el.current && typedSignature) { + const ctx = $el.current.getContext('2d'); + + if (ctx) { + const canvasWidth = $el.current.width; + const canvasHeight = $el.current.height; + + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = selectedColor; + + // Calculate the desired width (25ch) + const desiredWidth = canvasWidth * 0.85; // 85% of canvas width + + // Start with a base font size + let fontSize = 18; + ctx.font = `${fontSize}px ${fontCaveat.style.fontFamily}`; + + // Measure 10 characters and calculate scale factor + const characterWidth = ctx.measureText('m'.repeat(10)).width; + const scaleFactor = desiredWidth / characterWidth; + + // Apply scale factor to font size + fontSize = fontSize * scaleFactor; + + // Adjust font size if it exceeds canvas width + ctx.font = `${fontSize}px ${fontCaveat.style.fontFamily}`; + + const textWidth = ctx.measureText(typedSignature).width; + + if (textWidth > desiredWidth) { + fontSize = fontSize * (desiredWidth / textWidth); + } + + // Set final font and render text + ctx.font = `${fontSize}px ${fontCaveat.style.fontFamily}`; + ctx.fillText(typedSignature, canvasWidth / 2, canvasHeight / 2); + } + } + }; + + const handleTypedSignatureChange = (event: React.ChangeEvent) => { + const newValue = event.target.value; + setTypedSignature(newValue); + + if (newValue.trim() !== '') { + onChange?.($el.current?.toDataURL() || null); + } else { + onChange?.(null); + } + }; + + useEffect(() => { + if (typedSignature.trim() !== '') { + renderTypedSignature(); + onChange?.($el.current?.toDataURL() || null); + } else { + onClearClick(); + } + }, [typedSignature, selectedColor]); + const onUndoClick = () => { - if (lines.length === 0) { + if (lines.length === 0 && typedSignature.length === 0) { return; } - const newLines = lines.slice(0, -1); - setLines(newLines); + if (typedSignature.length > 0) { + const newTypedSignature = typedSignature.slice(0, -1); + setTypedSignature(newTypedSignature); + // You might want to call onChange here as well + // onChange?.(newTypedSignature); + } else { + const newLines = lines.slice(0, -1); + setLines(newLines); - // Clear the canvas - if ($el.current) { - const ctx = $el.current.getContext('2d'); - const { width, height } = $el.current; - ctx?.clearRect(0, 0, width, height); + // Clear and redraw the canvas + if ($el.current) { + const ctx = $el.current.getContext('2d'); + const { width, height } = $el.current; + ctx?.clearRect(0, 0, width, height); - if (typeof defaultValue === 'string' && $imageData.current) { - ctx?.putImageData($imageData.current, 0, 0); + if (typeof defaultValue === 'string' && $imageData.current) { + ctx?.putImageData($imageData.current, 0, 0); + } + + newLines.forEach((line) => { + const pathData = new Path2D( + getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)), + ); + ctx?.fill(pathData); + }); + + onChange?.($el.current.toDataURL()); } - - newLines.forEach((line) => { - const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions))); - ctx?.fill(pathData); - }); - - onChange?.($el.current.toDataURL()); } }; @@ -263,6 +349,21 @@ export const SignaturePad = ({ {...props} /> + {allowTypedSignature && ( +
0 || typedSignature.length > 0, + })} + > + +
+ )} +