Sign Document
diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx
index bbe18fb8a..6e661e77a 100644
--- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx
@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
-import { Recipient } from '@documenso/prisma/client';
-import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
+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';
@@ -98,7 +98,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
};
return (
-
+
{isLoading && (
diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx
index 97babb82f..18b81696e 100644
--- a/apps/web/src/app/(signing)/sign/[token]/page.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx
@@ -2,9 +2,12 @@ import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern';
+import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
+import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
+import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
@@ -42,6 +45,8 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
viewedDocument({ token }).catch(() => null),
]);
+ const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
+
if (!document || !document.documentData || !recipient) {
return notFound();
}
@@ -111,7 +116,13 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
))
.with(FieldType.DATE, () => (
-
+
))
.with(FieldType.EMAIL, () => (
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 ec3e45fe5..220d3084a 100644
--- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx
@@ -127,7 +127,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
};
return (
-
+
{isLoading && (
diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx
index 046e5b3df..b4805fa6b 100644
--- a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx
@@ -2,8 +2,9 @@
import React from 'react';
-import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
+import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type SignatureFieldProps = {
field: FieldWithSignature;
@@ -11,6 +12,8 @@ export type SignatureFieldProps = {
children: React.ReactNode;
onSign?: () => Promise
| void;
onRemove?: () => Promise | void;
+ type?: 'Date' | 'Email' | 'Name' | 'Signature';
+ tooltipText?: string | null;
};
export const SigningFieldContainer = ({
@@ -19,6 +22,8 @@ export const SigningFieldContainer = ({
onSign,
onRemove,
children,
+ type,
+ tooltipText,
}: SignatureFieldProps) => {
const onSignFieldClick = async () => {
if (field.inserted) {
@@ -46,7 +51,22 @@ export const SigningFieldContainer = ({
/>
)}
- {field.inserted && !loading && (
+ {type === 'Date' && field.inserted && !loading && (
+
+
+
+
+
+ {tooltipText && {tooltipText}}
+
+ )}
+
+ {type !== 'Date' && field.inserted && !loading && (
-
- {/* We have no other subpaths rn */}
- {/*
- Documents
- */}
);
};
diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx
index 25f260575..cf8873a1a 100644
--- a/apps/web/src/components/(dashboard)/layout/header.tsx
+++ b/apps/web/src/components/(dashboard)/layout/header.tsx
@@ -49,7 +49,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
-
+
{/*
diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
index e488ba6e9..2dcbb9864 100644
--- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
+++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
@@ -4,6 +4,7 @@ import Link from 'next/link';
import {
CreditCard,
+ FileSpreadsheet,
Lock,
LogOut,
User as LucideUser,
@@ -106,6 +107,13 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
)}
+
+
+
+
+ Templates
+
+
diff --git a/apps/web/src/components/formatter/document-status.tsx b/apps/web/src/components/formatter/document-status.tsx
index 17cd182a8..3deaa302a 100644
--- a/apps/web/src/components/formatter/document-status.tsx
+++ b/apps/web/src/components/formatter/document-status.tsx
@@ -1,9 +1,9 @@
-import { HTMLAttributes } from 'react';
+import type { HTMLAttributes } from 'react';
import { CheckCircle2, Clock, File } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
-import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
+import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils';
diff --git a/apps/web/src/components/formatter/locale-date.tsx b/apps/web/src/components/formatter/locale-date.tsx
index ecefb1e3b..7262a9a57 100644
--- a/apps/web/src/components/formatter/locale-date.tsx
+++ b/apps/web/src/components/formatter/locale-date.tsx
@@ -1,8 +1,10 @@
'use client';
-import { HTMLAttributes, useEffect, useState } from 'react';
+import type { HTMLAttributes } from 'react';
+import { useEffect, useState } from 'react';
-import { DateTime, DateTimeFormatOptions } from 'luxon';
+import type { DateTimeFormatOptions } from 'luxon';
+import { DateTime } from 'luxon';
import { useLocale } from '@documenso/lib/client-only/providers/locale';
diff --git a/apps/web/src/components/formatter/template-type.tsx b/apps/web/src/components/formatter/template-type.tsx
new file mode 100644
index 000000000..a7f10105e
--- /dev/null
+++ b/apps/web/src/components/formatter/template-type.tsx
@@ -0,0 +1,50 @@
+import { HTMLAttributes } from 'react';
+
+import { Globe, Lock } from 'lucide-react';
+import type { LucideIcon } from 'lucide-react/dist/lucide-react';
+
+import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
+import { cn } from '@documenso/ui/lib/utils';
+
+type TemplateTypeIcon = {
+ label: string;
+ icon?: LucideIcon;
+ color: string;
+};
+
+type TemplateTypes = (typeof TemplateTypePrisma)[keyof typeof TemplateTypePrisma];
+
+const TEMPLATE_TYPES: Record = {
+ PRIVATE: {
+ label: 'Private',
+ icon: Lock,
+ color: 'text-blue-600 dark:text-blue-300',
+ },
+ PUBLIC: {
+ label: 'Public',
+ icon: Globe,
+ color: 'text-green-500 dark:text-green-300',
+ },
+};
+
+export type TemplateTypeProps = HTMLAttributes & {
+ type: TemplateTypes;
+ inheritColor?: boolean;
+};
+
+export const TemplateType = ({ className, type, inheritColor, ...props }: TemplateTypeProps) => {
+ const { label, icon: Icon, color } = TEMPLATE_TYPES[type];
+
+ return (
+
+ {Icon && (
+
+ )}
+ {label}
+
+ );
+};
diff --git a/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx
index eac574181..eafed5500 100644
--- a/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx
+++ b/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx
@@ -23,6 +23,7 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
+import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZDisableTwoFactorAuthenticationForm = z.object({
@@ -107,38 +108,42 @@ export const DisableAuthenticatorAppDialog = ({
)}
className="flex flex-col gap-y-4"
>
- (
-
- Password
-
-
-
-
-
- )}
- />
+
onOpenChange(false)}>
diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx
index 8bf835ef5..0db1c8b50 100644
--- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx
+++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx
@@ -27,6 +27,7 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
+import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list';
@@ -178,9 +179,8 @@ export const EnableAuthenticatorAppDialog = ({
Password
-
diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
index 6275f16d6..5590a6722 100644
--- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
+++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
@@ -22,7 +22,7 @@ import {
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
-import { Input } from '@documenso/ui/primitives/input';
+import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list';
@@ -108,9 +108,8 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
Password
-
diff --git a/apps/web/src/components/forms/forgot-password.tsx b/apps/web/src/components/forms/forgot-password.tsx
index 141f3780f..3d9efee42 100644
--- a/apps/web/src/components/forms/forgot-password.tsx
+++ b/apps/web/src/components/forms/forgot-password.tsx
@@ -9,9 +9,15 @@ import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
-import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
-import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZForgotPasswordFormSchema = z.object({
@@ -28,18 +34,15 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
const router = useRouter();
const { toast } = useToast();
- const {
- register,
- handleSubmit,
- reset,
- formState: { errors, isSubmitting },
- } = useForm({
+ const form = useForm({
values: {
email: '',
},
resolver: zodResolver(ZForgotPasswordFormSchema),
});
+ const isSubmitting = form.formState.isSubmitting;
+
const { mutateAsync: forgotPassword } = trpc.profile.forgotPassword.useMutation();
const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => {
@@ -52,29 +55,37 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
duration: 5000,
});
- reset();
+ form.reset();
router.push('/check-email');
};
return (
-
+
+ {isSubmitting ? 'Sending Reset Email...' : 'Reset Password'}
+
+
+
);
};
diff --git a/apps/web/src/components/forms/password.tsx b/apps/web/src/components/forms/password.tsx
index 47cba1e88..0eb491537 100644
--- a/apps/web/src/components/forms/password.tsx
+++ b/apps/web/src/components/forms/password.tsx
@@ -1,23 +1,25 @@
'use client';
-import { useState } from 'react';
-
import { zodResolver } from '@hookform/resolvers/zod';
-import { Eye, EyeOff, Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
-import { User } from '@documenso/prisma/client';
+import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
-import { Input } from '@documenso/ui/primitives/input';
-import { Label } from '@documenso/ui/primitives/label';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
-import { FormErrorMessage } from '../form/form-error-message';
-
export const ZPasswordFormSchema = z
.object({
currentPassword: z
@@ -48,16 +50,7 @@ export type PasswordFormProps = {
export const PasswordForm = ({ className }: PasswordFormProps) => {
const { toast } = useToast();
- const [showPassword, setShowPassword] = useState(false);
- const [showConfirmPassword, setShowConfirmPassword] = useState(false);
- const [showCurrentPassword, setShowCurrentPassword] = useState(false);
-
- const {
- register,
- handleSubmit,
- reset,
- formState: { errors, isSubmitting },
- } = useForm({
+ const form = useForm({
values: {
currentPassword: '',
password: '',
@@ -66,6 +59,8 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
resolver: zodResolver(ZPasswordFormSchema),
});
+ const isSubmitting = form.formState.isSubmitting;
+
const { mutateAsync: updatePassword } = trpc.profile.updatePassword.useMutation();
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
@@ -75,7 +70,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
password,
});
- reset();
+ form.reset();
toast({
title: 'Password updated',
@@ -101,117 +96,61 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
};
return (
-
diff --git a/packages/ui/primitives/document-flow/add-subject.types.ts b/packages/ui/primitives/document-flow/add-subject.types.ts
index 33e2dedfb..ea14f4c0f 100644
--- a/packages/ui/primitives/document-flow/add-subject.types.ts
+++ b/packages/ui/primitives/document-flow/add-subject.types.ts
@@ -1,9 +1,14 @@
import { z } from 'zod';
+import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
+import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
+
export const ZAddSubjectFormSchema = z.object({
- email: z.object({
+ meta: z.object({
subject: z.string(),
message: z.string(),
+ timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
+ dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
}),
});
diff --git a/packages/ui/primitives/document-flow/document-flow-root.tsx b/packages/ui/primitives/document-flow/document-flow-root.tsx
index 42b70c58a..74a232e1d 100644
--- a/packages/ui/primitives/document-flow/document-flow-root.tsx
+++ b/packages/ui/primitives/document-flow/document-flow-root.tsx
@@ -22,12 +22,12 @@ export const DocumentFlowFormContainer = ({
- {children}
+ {children}
);
};
@@ -63,10 +63,7 @@ export const DocumentFlowFormContainerContent = ({
}: DocumentFlowFormContainerContentProps) => {
return (
{children}
diff --git a/packages/ui/primitives/document-flow/types.ts b/packages/ui/primitives/document-flow/types.ts
index 677dc931b..82f5706e6 100644
--- a/packages/ui/primitives/document-flow/types.ts
+++ b/packages/ui/primitives/document-flow/types.ts
@@ -24,7 +24,7 @@ export const ZDocumentFlowFormSchema = z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
type: z.nativeEnum(FieldType),
- signerEmail: z.string().min(1),
+ signerEmail: z.string().min(1).optional(),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
diff --git a/packages/ui/primitives/input.tsx b/packages/ui/primitives/input.tsx
index ac739c984..1a5fba1bb 100644
--- a/packages/ui/primitives/input.tsx
+++ b/packages/ui/primitives/input.tsx
@@ -1,9 +1,6 @@
import * as React from 'react';
-import { Eye, EyeOff } from 'lucide-react';
-
import { cn } from '../lib/utils';
-import { Button } from './button';
export type InputProps = React.InputHTMLAttributes
;
@@ -28,38 +25,4 @@ const Input = React.forwardRef(
Input.displayName = 'Input';
-const PasswordInput = React.forwardRef(
- ({ className, ...props }, ref) => {
- const [showPassword, setShowPassword] = React.useState(false);
-
- return (
-
-
-
- setShowPassword((show) => !show)}
- >
- {showPassword ? (
-
- ) : (
-
- )}
-
-
- );
- },
-);
-
-PasswordInput.displayName = 'Input';
-
-export { Input, PasswordInput };
+export { Input };
diff --git a/packages/ui/primitives/multiselect-combobox.tsx b/packages/ui/primitives/multiselect-combobox.tsx
new file mode 100644
index 000000000..bac87ce0b
--- /dev/null
+++ b/packages/ui/primitives/multiselect-combobox.tsx
@@ -0,0 +1,82 @@
+import * as React from 'react';
+
+import { Check, ChevronsUpDown } from 'lucide-react';
+
+import { Role } from '@documenso/prisma/client';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from '@documenso/ui/primitives/command';
+import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
+
+type ComboboxProps = {
+ listValues: string[];
+ onChange: (_values: string[]) => void;
+};
+
+const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
+ const [open, setOpen] = React.useState(false);
+ const [selectedValues, setSelectedValues] = React.useState([]);
+ const dbRoles = Object.values(Role);
+
+ React.useEffect(() => {
+ setSelectedValues(listValues);
+ }, [listValues]);
+
+ const allRoles = [...new Set([...dbRoles, ...selectedValues])];
+
+ const handleSelect = (currentValue: string) => {
+ let newSelectedValues;
+ if (selectedValues.includes(currentValue)) {
+ newSelectedValues = selectedValues.filter((value) => value !== currentValue);
+ } else {
+ newSelectedValues = [...selectedValues, currentValue];
+ }
+
+ setSelectedValues(newSelectedValues);
+ onChange(newSelectedValues);
+ setOpen(false);
+ };
+
+ return (
+
+
+
+ {selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'}
+
+
+
+
+
+
+ No value found.
+
+ {allRoles.map((value: string, i: number) => (
+ handleSelect(value)}>
+
+ {value}
+
+ ))}
+
+
+
+
+ );
+};
+
+export { MultiSelectCombobox };
diff --git a/packages/ui/primitives/password-input.tsx b/packages/ui/primitives/password-input.tsx
new file mode 100644
index 000000000..502344a02
--- /dev/null
+++ b/packages/ui/primitives/password-input.tsx
@@ -0,0 +1,42 @@
+import * as React from 'react';
+
+import { Eye, EyeOff } from 'lucide-react';
+
+import { cn } from '../lib/utils';
+import { Button } from './button';
+import { Input, InputProps } from './input';
+
+const PasswordInput = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ const [showPassword, setShowPassword] = React.useState(false);
+
+ return (
+
+
+
+ setShowPassword((show) => !show)}
+ >
+ {showPassword ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ },
+);
+
+PasswordInput.displayName = 'PasswordInput';
+
+export { PasswordInput };
diff --git a/packages/ui/primitives/signature-pad/signature-pad.tsx b/packages/ui/primitives/signature-pad/signature-pad.tsx
index 3497418d7..80bac0e18 100644
--- a/packages/ui/primitives/signature-pad/signature-pad.tsx
+++ b/packages/ui/primitives/signature-pad/signature-pad.tsx
@@ -3,6 +3,7 @@
import type { HTMLAttributes, MouseEvent, PointerEvent, TouchEvent } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
+import { Undo2 } from 'lucide-react';
import type { StrokeOptions } from 'perfect-freehand';
import { getStroke } from 'perfect-freehand';
@@ -27,7 +28,8 @@ export const SignaturePad = ({
const $el = useRef(null);
const [isPressed, setIsPressed] = useState(false);
- const [points, setPoints] = useState([]);
+ const [lines, setLines] = useState([]);
+ const [currentLine, setCurrentLine] = useState([]);
const perfectFreehandOptions = useMemo(() => {
const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10;
@@ -52,26 +54,7 @@ export const SignaturePad = ({
const point = Point.fromEvent(event, DPI, $el.current);
- const newPoints = [...points, point];
-
- setPoints(newPoints);
-
- if ($el.current) {
- const ctx = $el.current.getContext('2d');
-
- if (ctx) {
- ctx.save();
-
- ctx.imageSmoothingEnabled = true;
- ctx.imageSmoothingQuality = 'high';
-
- const pathData = new Path2D(
- getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
- );
-
- ctx.fill(pathData);
- }
- }
+ setCurrentLine([point]);
};
const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => {
@@ -85,31 +68,36 @@ export const SignaturePad = ({
const point = Point.fromEvent(event, DPI, $el.current);
- if (point.distanceTo(points[points.length - 1]) > 5) {
- const newPoints = [...points, point];
-
- setPoints(newPoints);
+ if (point.distanceTo(currentLine[currentLine.length - 1]) > 5) {
+ setCurrentLine([...currentLine, point]);
+ // Update the canvas here to draw the lines
if ($el.current) {
const ctx = $el.current.getContext('2d');
if (ctx) {
ctx.restore();
-
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
- const pathData = new Path2D(
- getSvgPathFromStroke(getStroke(points, perfectFreehandOptions)),
- );
+ lines.forEach((line) => {
+ const pathData = new Path2D(
+ getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)),
+ );
+ ctx.fill(pathData);
+ });
+
+ const pathData = new Path2D(
+ getSvgPathFromStroke(getStroke([...currentLine, point], perfectFreehandOptions)),
+ );
ctx.fill(pathData);
}
}
}
};
- const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addPoint = true) => {
+ const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addLine = true) => {
if (event.cancelable) {
event.preventDefault();
}
@@ -118,15 +106,16 @@ export const SignaturePad = ({
const point = Point.fromEvent(event, DPI, $el.current);
- const newPoints = [...points];
+ const newLines = [...lines];
- if (addPoint) {
- newPoints.push(point);
-
- setPoints(newPoints);
+ if (addLine && currentLine.length > 0) {
+ newLines.push([...currentLine, point]);
+ setCurrentLine([]);
}
- if ($el.current && newPoints.length > 0) {
+ setLines(newLines);
+
+ if ($el.current && newLines.length > 0) {
const ctx = $el.current.getContext('2d');
if (ctx) {
@@ -135,19 +124,18 @@ export const SignaturePad = ({
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
- const pathData = new Path2D(
- getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
- );
+ newLines.forEach((line) => {
+ const pathData = new Path2D(
+ getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)),
+ );
+ ctx.fill(pathData);
+ });
- ctx.fill(pathData);
+ onChange?.($el.current.toDataURL());
ctx.save();
}
-
- onChange?.($el.current.toDataURL());
}
-
- setPoints([]);
};
const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => {
@@ -177,7 +165,29 @@ export const SignaturePad = ({
onChange?.(null);
- setPoints([]);
+ setLines([]);
+ setCurrentLine([]);
+ };
+
+ const onUndoClick = () => {
+ if (lines.length === 0) {
+ return;
+ }
+
+ const newLines = [...lines];
+ newLines.pop(); // Remove the last line
+ setLines(newLines);
+
+ // Clear the canvas
+ if ($el.current) {
+ const ctx = $el.current.getContext('2d');
+ ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
+
+ newLines.forEach((line) => {
+ const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)));
+ ctx?.fill(pathData);
+ });
+ }
};
useEffect(() => {
@@ -217,15 +227,29 @@ export const SignaturePad = ({
{...props}
/>
-
+
onClearClick()}
>
Clear Signature
+
+ {lines.length > 0 && (
+
+ onUndoClick()}
+ >
+
+ Undo
+
+
+ )}
);
};
diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx
new file mode 100644
index 000000000..bb9c304d9
--- /dev/null
+++ b/packages/ui/primitives/template-flow/add-template-fields.tsx
@@ -0,0 +1,539 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { Caveat } from 'next/font/google';
+
+import { ChevronsUpDown } from 'lucide-react';
+import { useFieldArray, useForm } from 'react-hook-form';
+
+import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
+import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
+import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
+import { nanoid } from '@documenso/lib/universal/id';
+import type { Field, Recipient } from '@documenso/prisma/client';
+import { FieldType } from '@documenso/prisma/client';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from '@documenso/ui/primitives/command';
+import {
+ DocumentFlowFormContainerActions,
+ DocumentFlowFormContainerContent,
+ DocumentFlowFormContainerFooter,
+ DocumentFlowFormContainerStep,
+} from '@documenso/ui/primitives/document-flow/document-flow-root';
+import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
+import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
+import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
+import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
+
+import { useStep } from '../stepper';
+// import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
+import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
+
+const fontCaveat = Caveat({
+ weight: ['500'],
+ subsets: ['latin'],
+ display: 'swap',
+ variable: '--font-caveat',
+});
+
+const DEFAULT_HEIGHT_PERCENT = 5;
+const DEFAULT_WIDTH_PERCENT = 15;
+
+const MIN_HEIGHT_PX = 60;
+const MIN_WIDTH_PX = 200;
+
+export type AddTemplateFieldsFormProps = {
+ documentFlow: DocumentFlowStep;
+ hideRecipients?: boolean;
+ recipients: Recipient[];
+ fields: Field[];
+ onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
+};
+
+export const AddTemplateFieldsFormPartial = ({
+ documentFlow,
+ hideRecipients = false,
+ recipients,
+ fields,
+ onSubmit,
+}: AddTemplateFieldsFormProps) => {
+ const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
+
+ const { currentStep, totalSteps, previousStep } = useStep();
+
+ const {
+ control,
+ handleSubmit,
+ formState: { isSubmitting },
+ } = useForm({
+ defaultValues: {
+ fields: fields.map((field) => ({
+ nativeId: field.id,
+ formId: `${field.id}-${field.templateId}`,
+ pageNumber: field.page,
+ type: field.type,
+ pageX: Number(field.positionX),
+ pageY: Number(field.positionY),
+ pageWidth: Number(field.width),
+ pageHeight: Number(field.height),
+ signerId: field.recipientId ?? -1,
+ signerEmail:
+ recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
+ signerToken:
+ recipients.find((recipient) => recipient.id === field.recipientId)?.token ?? '',
+ })),
+ },
+ });
+
+ const onFormSubmit = handleSubmit(onSubmit);
+
+ const {
+ append,
+ remove,
+ update,
+ fields: localFields,
+ } = useFieldArray({
+ control,
+ name: 'fields',
+ });
+
+ const [selectedField, setSelectedField] = useState(null);
+ const [selectedSigner, setSelectedSigner] = useState(null);
+ const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
+
+ const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
+ const [coords, setCoords] = useState({
+ x: 0,
+ y: 0,
+ });
+
+ const fieldBounds = useRef({
+ height: 0,
+ width: 0,
+ });
+
+ const onMouseMove = useCallback(
+ (event: MouseEvent) => {
+ setIsFieldWithinBounds(
+ isWithinPageBounds(
+ event,
+ PDF_VIEWER_PAGE_SELECTOR,
+ fieldBounds.current.width,
+ fieldBounds.current.height,
+ ),
+ );
+
+ setCoords({
+ x: event.clientX - fieldBounds.current.width / 2,
+ y: event.clientY - fieldBounds.current.height / 2,
+ });
+ },
+ [isWithinPageBounds],
+ );
+
+ const onMouseClick = useCallback(
+ (event: MouseEvent) => {
+ if (!selectedField || !selectedSigner) {
+ return;
+ }
+
+ const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
+
+ if (
+ !$page ||
+ !isWithinPageBounds(
+ event,
+ PDF_VIEWER_PAGE_SELECTOR,
+ fieldBounds.current.width,
+ fieldBounds.current.height,
+ )
+ ) {
+ setSelectedField(null);
+ return;
+ }
+
+ const { top, left, height, width } = getBoundingClientRect($page);
+
+ const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
+
+ // Calculate x and y as a percentage of the page width and height
+ let pageX = ((event.pageX - left) / width) * 100;
+ let pageY = ((event.pageY - top) / height) * 100;
+
+ // Get the bounds as a percentage of the page width and height
+ const fieldPageWidth = (fieldBounds.current.width / width) * 100;
+ const fieldPageHeight = (fieldBounds.current.height / height) * 100;
+
+ // And center it based on the bounds
+ pageX -= fieldPageWidth / 2;
+ pageY -= fieldPageHeight / 2;
+
+ append({
+ formId: nanoid(12),
+ type: selectedField,
+ pageNumber,
+ pageX,
+ pageY,
+ pageWidth: fieldPageWidth,
+ pageHeight: fieldPageHeight,
+ signerEmail: selectedSigner.email,
+ signerId: selectedSigner.id,
+ signerToken: selectedSigner.token ?? '',
+ });
+
+ setIsFieldWithinBounds(false);
+ setSelectedField(null);
+ },
+ [append, isWithinPageBounds, selectedField, selectedSigner, getPage],
+ );
+
+ const onFieldResize = useCallback(
+ (node: HTMLElement, index: number) => {
+ const field = localFields[index];
+
+ const $page = window.document.querySelector(
+ `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
+ );
+
+ if (!$page) {
+ return;
+ }
+
+ const {
+ x: pageX,
+ y: pageY,
+ width: pageWidth,
+ height: pageHeight,
+ } = getFieldPosition($page, node);
+
+ update(index, {
+ ...field,
+ pageX,
+ pageY,
+ pageWidth,
+ pageHeight,
+ });
+ },
+ [getFieldPosition, localFields, update],
+ );
+
+ const onFieldMove = useCallback(
+ (node: HTMLElement, index: number) => {
+ const field = localFields[index];
+
+ const $page = window.document.querySelector(
+ `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
+ );
+
+ if (!$page) {
+ return;
+ }
+
+ const { x: pageX, y: pageY } = getFieldPosition($page, node);
+
+ update(index, {
+ ...field,
+ pageX,
+ pageY,
+ });
+ },
+ [getFieldPosition, localFields, update],
+ );
+
+ useEffect(() => {
+ if (selectedField) {
+ window.addEventListener('mousemove', onMouseMove);
+ window.addEventListener('mouseup', onMouseClick);
+ }
+
+ return () => {
+ window.removeEventListener('mousemove', onMouseMove);
+ window.removeEventListener('mouseup', onMouseClick);
+ };
+ }, [onMouseClick, onMouseMove, selectedField]);
+
+ useEffect(() => {
+ const observer = new MutationObserver((_mutations) => {
+ const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
+
+ if (!$page) {
+ return;
+ }
+
+ const { height, width } = $page.getBoundingClientRect();
+
+ fieldBounds.current = {
+ height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
+ width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
+ };
+ });
+
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ });
+
+ return () => {
+ observer.disconnect();
+ };
+ }, []);
+
+ useEffect(() => {
+ setSelectedSigner(recipients[0]);
+ }, [recipients]);
+
+ return (
+ <>
+
+
+ {selectedField && (
+
+
+ {FRIENDLY_FIELD_TYPE[selectedField]}
+
+
+ )}
+
+ {localFields.map((field, index) => (
+
onFieldResize(options, index)}
+ onMove={(options) => onFieldMove(options, index)}
+ onRemove={() => remove(index)}
+ />
+ ))}
+
+ {!hideRecipients && (
+
+
+
+ {selectedSigner?.email && (
+
+ {selectedSigner?.name} ({selectedSigner?.email})
+
+ )}
+
+ {!selectedSigner?.email && (
+ {selectedSigner?.email}
+ )}
+
+
+
+
+
+
+
+
+
+
+ No recipient matching this description was found.
+
+
+
+
+ {recipients.map((recipient, index) => (
+ {
+ setSelectedSigner(recipient);
+ setShowRecipientsSelector(false);
+ }}
+ >
+ {/* {recipient.sendStatus !== SendStatus.SENT ? (
+
+ ) : (
+
+
+
+
+
+ This document has already been sent to this recipient. You can no
+ longer edit this recipient.
+
+
+ )} */}
+
+ {recipient.name && (
+
+ {recipient.name} ({recipient.email})
+
+ )}
+
+ {!recipient.name && (
+
+ {recipient.email}
+
+ )}
+
+ ))}
+
+
+
+
+ )}
+
+
+
+
setSelectedField(FieldType.SIGNATURE)}
+ onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
+ data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
+ >
+
+
+
+ {selectedSigner?.name || 'Signature'}
+
+
+ Signature
+
+
+
+
+
setSelectedField(FieldType.EMAIL)}
+ onMouseDown={() => setSelectedField(FieldType.EMAIL)}
+ data-selected={selectedField === FieldType.EMAIL ? true : undefined}
+ >
+
+
+
+ {'Email'}
+
+
+ Email
+
+
+
+
+
setSelectedField(FieldType.NAME)}
+ onMouseDown={() => setSelectedField(FieldType.NAME)}
+ data-selected={selectedField === FieldType.NAME ? true : undefined}
+ >
+
+
+
+ {'Name'}
+
+
+ Name
+
+
+
+
+
setSelectedField(FieldType.DATE)}
+ onMouseDown={() => setSelectedField(FieldType.DATE)}
+ data-selected={selectedField === FieldType.DATE ? true : undefined}
+ >
+
+
+
+ {'Date'}
+
+
+ Date
+
+
+
+
+
+
+
+
+
+
+
+ {
+ previousStep();
+ remove();
+ }}
+ onGoNextClick={() => void onFormSubmit()}
+ />
+
+ >
+ );
+};
diff --git a/packages/ui/primitives/template-flow/add-template-fields.types.ts b/packages/ui/primitives/template-flow/add-template-fields.types.ts
new file mode 100644
index 000000000..4406f82a0
--- /dev/null
+++ b/packages/ui/primitives/template-flow/add-template-fields.types.ts
@@ -0,0 +1,23 @@
+import { z } from 'zod';
+
+import { FieldType } from '@documenso/prisma/client';
+
+export const ZAddTemplateFieldsFormSchema = z.object({
+ fields: z.array(
+ z.object({
+ formId: z.string().min(1),
+ nativeId: z.number().optional(),
+ type: z.nativeEnum(FieldType),
+ signerEmail: z.string().min(1),
+ signerToken: z.string(),
+ signerId: z.number().optional(),
+ pageNumber: z.number().min(1),
+ pageX: z.number().min(0),
+ pageY: z.number().min(0),
+ pageWidth: z.number().min(0),
+ pageHeight: z.number().min(0),
+ }),
+ ),
+});
+
+export type TAddTemplateFieldsFormSchema = z.infer;
diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
new file mode 100644
index 000000000..ebe48b562
--- /dev/null
+++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
@@ -0,0 +1,193 @@
+'use client';
+
+import React, { useId, useState } from 'react';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { AnimatePresence, motion } from 'framer-motion';
+import { Plus, Trash } from 'lucide-react';
+import { useFieldArray, useForm } from 'react-hook-form';
+
+import { nanoid } from '@documenso/lib/universal/id';
+import type { Field, Recipient } from '@documenso/prisma/client';
+import { Button } from '@documenso/ui/primitives/button';
+import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
+import { Input } from '@documenso/ui/primitives/input';
+import { Label } from '@documenso/ui/primitives/label';
+
+import {
+ DocumentFlowFormContainerActions,
+ DocumentFlowFormContainerContent,
+ DocumentFlowFormContainerFooter,
+ DocumentFlowFormContainerStep,
+} from '../document-flow/document-flow-root';
+import type { DocumentFlowStep } from '../document-flow/types';
+import { useStep } from '../stepper';
+import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
+import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
+
+export type AddTemplatePlaceholderRecipientsFormProps = {
+ documentFlow: DocumentFlowStep;
+ recipients: Recipient[];
+ fields: Field[];
+ onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
+};
+
+export const AddTemplatePlaceholderRecipientsFormPartial = ({
+ documentFlow,
+ recipients,
+ fields: _fields,
+ onSubmit,
+}: AddTemplatePlaceholderRecipientsFormProps) => {
+ const initialId = useId();
+ const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() =>
+ recipients.length > 1 ? recipients.length + 1 : 2,
+ );
+
+ const { currentStep, totalSteps, previousStep } = useStep();
+
+ const {
+ control,
+ handleSubmit,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema),
+ defaultValues: {
+ signers:
+ recipients.length > 0
+ ? recipients.map((recipient) => ({
+ nativeId: recipient.id,
+ formId: String(recipient.id),
+ name: recipient.name,
+ email: recipient.email,
+ }))
+ : [
+ {
+ formId: initialId,
+ name: `Recipient 1`,
+ email: `recipient.1@documenso.com`,
+ },
+ ],
+ },
+ });
+
+ const onFormSubmit = handleSubmit(onSubmit);
+
+ const {
+ append: appendSigner,
+ fields: signers,
+ remove: removeSigner,
+ } = useFieldArray({
+ control,
+ name: 'signers',
+ });
+
+ const onAddPlaceholderRecipient = () => {
+ appendSigner({
+ formId: nanoid(12),
+ name: `Recipient ${placeholderRecipientCount}`,
+ email: `recipient.${placeholderRecipientCount}@documenso.com`,
+ });
+
+ setPlaceholderRecipientCount((count) => count + 1);
+ };
+
+ const onRemoveSigner = (index: number) => {
+ removeSigner(index);
+ };
+
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
+ onAddPlaceholderRecipient();
+ }
+ };
+
+ return (
+ <>
+
+
+
+ {signers.map((signer, index) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ onRemoveSigner(index)}
+ >
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
onAddPlaceholderRecipient()}>
+
+ Add Placeholder Recipient
+
+
+
+
+
+
+
+ 1}
+ onGoBackClick={() => previousStep()}
+ onGoNextClick={() => void onFormSubmit()}
+ />
+
+ >
+ );
+};
diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts
new file mode 100644
index 000000000..780405a0c
--- /dev/null
+++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts
@@ -0,0 +1,26 @@
+import { z } from 'zod';
+
+export const ZAddTemplatePlacholderRecipientsFormSchema = z
+ .object({
+ signers: z.array(
+ z.object({
+ formId: z.string().min(1),
+ nativeId: z.number().optional(),
+ email: z.string().min(1).email(),
+ name: z.string(),
+ }),
+ ),
+ })
+ .refine(
+ (schema) => {
+ const emails = schema.signers.map((signer) => signer.email.toLowerCase());
+
+ return new Set(emails).size === emails.length;
+ },
+ // Dirty hack to handle errors when .root is populated for an array type
+ { message: 'Signers must have unique emails', path: ['signers__root'] },
+ );
+
+export type TAddTemplatePlacholderRecipientsFormSchema = z.infer<
+ typeof ZAddTemplatePlacholderRecipientsFormSchema
+>;
diff --git a/packages/ui/primitives/theme-switcher.tsx b/packages/ui/primitives/theme-switcher.tsx
index 7aa570749..fcc789404 100644
--- a/packages/ui/primitives/theme-switcher.tsx
+++ b/packages/ui/primitives/theme-switcher.tsx
@@ -4,6 +4,8 @@ import { useTheme } from 'next-themes';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
+import { THEMES_TYPE } from './constants';
+
export const ThemeSwitcher = () => {
const { theme, setTheme } = useTheme();
const isMounted = useIsMounted();
@@ -12,9 +14,9 @@ export const ThemeSwitcher = () => {
setTheme('light')}
+ onClick={() => setTheme(THEMES_TYPE.LIGHT)}
>
- {isMounted && theme === 'light' && (
+ {isMounted && theme === THEMES_TYPE.LIGHT && (
{
setTheme('dark')}
+ onClick={() => setTheme(THEMES_TYPE.DARK)}
>
- {isMounted && theme === 'dark' && (
+ {isMounted && theme === THEMES_TYPE.DARK && (
{
setTheme('system')}
+ onClick={() => setTheme(THEMES_TYPE.SYSTEM)}
>
- {isMounted && theme === 'system' && (
+ {isMounted && theme === THEMES_TYPE.SYSTEM && (