This commit is contained in:
David Nguyen
2025-01-31 23:17:50 +11:00
parent aec44b78d0
commit e20cb7e179
79 changed files with 3613 additions and 300 deletions

View File

@ -4,6 +4,7 @@ import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@ -20,14 +21,12 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useAuth } from '~/providers/auth';
export type AccountDeleteDialogProps = {
className?: string;
};
export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) => {
const { user } = useAuth();
const { user } = useSession();
const { _ } = useLingui();
const { toast } = useToast();

View File

@ -3,7 +3,7 @@ import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Document } from '@prisma/client';
import { useNavigation } from 'react-router';
import { useNavigate } from 'react-router';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
@ -28,7 +28,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigation();
const navigate = useNavigate();
const [reason, setReason] = useState('');
@ -49,7 +49,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
duration: 5000,
});
void navigate('/admin/documents');
await navigate('/admin/documents');
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),

View File

@ -51,15 +51,14 @@ export const DocumentDuplicateDialog = ({
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: ({ documentId }) => {
void navigate(`${documentsPath}/${documentId}/edit`);
onSuccess: async ({ documentId }) => {
toast({
title: _(msg`Document Duplicated`),
description: _(msg`Your document has been successfully duplicated.`),
duration: 5000,
});
await navigate(`${documentsPath}/${documentId}/edit`);
onOpenChange(false);
},
});

View File

@ -9,6 +9,7 @@ import { History } from 'lucide-react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { trpc as trpcReact } from '@documenso/trpc/react';
@ -35,7 +36,6 @@ import {
import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
import { useAuth } from '~/providers/auth';
import { useOptionalCurrentTeam } from '~/providers/team';
const FORM_ID = 'resend-email';
@ -56,7 +56,7 @@ export const ZResendDocumentFormSchema = z.object({
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
const { user } = useAuth();
const { user } = useSession();
const team = useOptionalCurrentTeam();
const { toast } = useToast();

View File

@ -0,0 +1,466 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Template, TemplateDirectLink } from '@prisma/client';
import { TemplateType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { P, match } from 'ts-pattern';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import {
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,
MAX_TEMPLATE_PUBLIC_TITLE_LENGTH,
} from '@documenso/trpc/server/template-router/schema';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type ManagePublicTemplateDialogProps = {
directTemplates: (Template & {
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
})[];
initialTemplateId?: number | null;
initialStep?: ProfileTemplateStep;
trigger?: React.ReactNode;
isOpen?: boolean;
onIsOpenChange?: (value: boolean) => unknown;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdatePublicTemplateFormSchema = z.object({
publicTitle: z
.string()
.min(1, { message: 'Title is required' })
.max(MAX_TEMPLATE_PUBLIC_TITLE_LENGTH, {
message: `Title cannot be longer than ${MAX_TEMPLATE_PUBLIC_TITLE_LENGTH} characters`,
}),
publicDescription: z
.string()
.min(1, { message: 'Description is required' })
.max(MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH, {
message: `Description cannot be longer than ${MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH} characters`,
}),
});
type TUpdatePublicTemplateFormSchema = z.infer<typeof ZUpdatePublicTemplateFormSchema>;
type ProfileTemplateStep = 'SELECT_TEMPLATE' | 'MANAGE' | 'CONFIRM_DISABLE';
export const ManagePublicTemplateDialog = ({
directTemplates,
trigger,
initialTemplateId = null,
initialStep = 'SELECT_TEMPLATE',
isOpen = false,
onIsOpenChange,
...props
}: ManagePublicTemplateDialogProps) => {
const { _, i18n } = useLingui();
const { toast } = useToast();
const [open, onOpenChange] = useState(isOpen);
const team = useOptionalCurrentTeam();
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(initialTemplateId);
const [currentStep, setCurrentStep] = useState<ProfileTemplateStep>(() => {
if (initialStep) {
return initialStep;
}
return selectedTemplateId ? 'MANAGE' : 'SELECT_TEMPLATE';
});
const form = useForm({
resolver: zodResolver(ZUpdatePublicTemplateFormSchema),
defaultValues: {
publicTitle: '',
publicDescription: '',
},
});
const { mutateAsync: updateTemplateSettings, isPending: isUpdatingTemplateSettings } =
trpc.template.updateTemplate.useMutation();
const setTemplateToPrivate = async (templateId: number) => {
try {
await updateTemplateSettings({
templateId,
data: {
type: TemplateType.PRIVATE,
},
});
toast({
title: _(msg`Success`),
description: _(msg`Template has been removed from your public profile.`),
duration: 5000,
});
handleOnOpenChange(false);
} catch {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to remove this template from your profile. Please try again later.`,
),
variant: 'destructive',
});
}
};
const onFormSubmit = async ({
publicTitle,
publicDescription,
}: TUpdatePublicTemplateFormSchema) => {
if (!selectedTemplateId) {
return;
}
try {
await updateTemplateSettings({
templateId: selectedTemplateId,
data: {
type: TemplateType.PUBLIC,
publicTitle,
publicDescription,
},
});
toast({
title: _(msg`Success`),
description: _(msg`Template has been updated.`),
duration: 5000,
});
onOpenChange(false);
} catch {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to update the template. Please try again later.`,
),
variant: 'destructive',
});
}
};
const selectedTemplate = useMemo(
() => directTemplates.find((template) => template.id === selectedTemplateId),
[directTemplates, selectedTemplateId],
);
const onManageStep = () => {
if (!selectedTemplate) {
return;
}
form.reset({
publicTitle: selectedTemplate.publicTitle,
publicDescription: selectedTemplate.publicDescription,
});
setCurrentStep('MANAGE');
};
const isLoading = isUpdatingTemplateSettings || form.formState.isSubmitting;
useEffect(() => {
const initialTemplate = directTemplates.find((template) => template.id === initialTemplateId);
if (initialTemplate) {
setSelectedTemplateId(initialTemplate.id);
form.reset({
publicTitle: initialTemplate.publicTitle,
publicDescription: initialTemplate.publicDescription,
});
} else {
setSelectedTemplateId(null);
}
const step = initialStep || (selectedTemplateId ? 'MANAGE' : 'SELECT_TEMPLATE');
setCurrentStep(step);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialTemplateId, initialStep, open, isOpen]);
const handleOnOpenChange = (value: boolean) => {
if (isLoading || typeof value !== 'boolean') {
return;
}
onOpenChange(value);
onIsOpenChange?.(value);
};
return (
<Dialog {...props} open={isOpen || open} onOpenChange={handleOnOpenChange}>
<fieldset disabled={isLoading} className="relative flex-shrink-0">
<DialogTrigger asChild>{trigger}</DialogTrigger>
<AnimateGenericFadeInOut motionKey={currentStep}>
{match({ templateId: selectedTemplateId, currentStep })
.with({ currentStep: 'SELECT_TEMPLATE' }, () => (
<DialogContent>
<DialogHeader>
<DialogTitle>
{team?.name ? (
<Trans>{team.name} direct signing templates</Trans>
) : (
<Trans>Your direct signing templates</Trans>
)}
</DialogTitle>
<DialogDescription>
{team ? (
<Trans>
Select a template you'd like to display on your team's public profile
</Trans>
) : (
<Trans>Select a template you'd like to display on your public profile</Trans>
)}
</DialogDescription>
</DialogHeader>
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>
<Trans>Template</Trans>
</TableHead>
<TableHead>
<Trans>Created</Trans>
</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{directTemplates.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="h-16 text-center">
<p className="text-muted-foreground">
<Trans>No valid direct templates found</Trans>
</p>
</TableCell>
</TableRow>
)}
{directTemplates.map((row) => (
<TableRow
className="w-full cursor-pointer"
key={row.id}
onClick={() => setSelectedTemplateId(row.id)}
>
<TableCell className="text-muted-foreground max-w-[30ch] text-sm">
{row.title}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{i18n.date(row.createdAt)}
</TableCell>
<TableCell>
{selectedTemplateId === row.id ? (
<CheckCircle2Icon className="h-5 w-5 text-neutral-600 dark:text-neutral-200" />
) : (
<CircleIcon className="h-5 w-5 text-neutral-300 dark:text-neutral-600" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Close</Trans>
</Button>
</DialogClose>
<Button
type="button"
disabled={selectedTemplateId === null}
onClick={() => onManageStep()}
>
<Trans>Continue</Trans>
</Button>
</DialogFooter>
</DialogContent>
))
.with({ templateId: P.number, currentStep: 'MANAGE' }, () => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>
<Trans>Configure template</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Manage details for this public template</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
className="flex h-full flex-col space-y-4"
onSubmit={form.handleSubmit(onFormSubmit)}
>
<FormField
control={form.control}
name="publicTitle"
render={({ field }) => (
<FormItem>
<FormLabel required>Title</FormLabel>
<FormControl>
<Input
placeholder={_(msg`The public name for your template`)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="publicDescription"
render={({ field }) => {
const remaningLength =
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH - (field.value || '').length;
return (
<FormItem>
<FormLabel required>Description</FormLabel>
<FormControl>
<Textarea
placeholder={_(
msg`The public description that will be displayed with this template`,
)}
{...field}
/>
</FormControl>
{!form.formState.errors.publicDescription && (
<p className="text-muted-foreground text-sm">
{remaningLength >= 0 ? (
<Plural
value={remaningLength}
one={<Trans># character remaining</Trans>}
other={<Trans># characters remaining</Trans>}
/>
) : (
<Plural
value={Math.abs(remaningLength)}
one={<Trans># character over the limit</Trans>}
other={<Trans># characters over the limit</Trans>}
/>
)}
</p>
)}
<FormMessage />
</FormItem>
);
}}
/>
<DialogFooter>
{selectedTemplate?.type === TemplateType.PUBLIC && (
<Button
variant="destructive"
className="mr-auto w-full sm:w-auto"
onClick={() => setCurrentStep('CONFIRM_DISABLE')}
>
<Trans>Disable</Trans>
</Button>
)}
<DialogClose asChild>
<Button variant="secondary">
<Trans>Close</Trans>
</Button>
</DialogClose>
<Button type="submit" loading={isUpdatingTemplateSettings}>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
))
.with({ templateId: P.number, currentStep: 'CONFIRM_DISABLE' }, ({ templateId }) => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>The template will be removed from your profile</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
loading={isUpdatingTemplateSettings}
onClick={() => void setTemplateToPrivate(templateId)}
>
<Trans>Confirm</Trans>
</Button>
</DialogFooter>
</DialogContent>
))
.otherwise(() => null)}
</AnimateGenericFadeInOut>
</fieldset>
</Dialog>
);
};

View File

@ -78,7 +78,7 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) =
setOpen(false);
if (response.paymentRequired) {
void navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
return;
}

View File

@ -71,9 +71,9 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialog
duration: 5000,
});
setOpen(false);
await navigate('/settings/teams');
void navigate('/settings/teams');
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);

View File

@ -0,0 +1,144 @@
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { FilePlus, Loader } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateCreateDialogProps = {
teamId?: number;
templateRootPath: string;
};
export const TemplateCreateDialog = ({ templateRootPath }: TemplateCreateDialogProps) => {
const navigate = useNavigate();
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false);
const onFileDrop = async (file: File) => {
if (isUploadingFile) {
return;
}
setIsUploadingFile(true);
try {
// const { type, data } = await putPdfFile(file);
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/file', {
method: 'POST',
body: formData,
})
.then((res) => res.json())
.catch((e) => {
console.error('Upload failed:', e);
throw new AppError('UPLOAD_FAILED');
});
// Why do we run this twice?
// const { id: templateDocumentDataId } = await createDocumentData({
// type: response.type,
// data: response.data,
// });
const { id } = await createTemplate({
title: file.name,
templateDocumentDataId: response.id,
});
toast({
title: _(msg`Template document uploaded`),
description: _(
msg`Your document has been uploaded successfully. You will be redirected to the template page.`,
),
duration: 5000,
});
setShowTemplateCreateDialog(false);
await navigate(`${templateRootPath}/${id}/edit`);
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Please try again later.`),
variant: 'destructive',
});
setIsUploadingFile(false);
}
};
return (
<Dialog
open={showTemplateCreateDialog}
onOpenChange={(value) => !isUploadingFile && setShowTemplateCreateDialog(value)}
>
<DialogTrigger asChild>
{/* Todo: Wouldn't this break for google? */}
<Button className="cursor-pointer" disabled={!user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
<Trans>New Template</Trans>
</Button>
</DialogTrigger>
<DialogContent className="w-full max-w-xl">
<DialogHeader>
<DialogTitle>
<Trans>New Template</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Templates allow you to quickly generate documents with pre-filled recipients and
fields.
</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
{isUploadingFile && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isUploadingFile}>
<Trans>Close</Trans>
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,86 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDeleteDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const TemplateDeleteDialog = ({ id, open, onOpenChange }: TemplateDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteTemplate, isPending } = trpcReact.template.deleteTemplate.useMutation({
onSuccess: () => {
// router.refresh(); // Todo
toast({
title: _(msg`Template deleted`),
description: _(msg`Your template has been successfully deleted.`),
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This template could not be deleted at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Do you want to delete this template?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Please note that this action is irreversible. Once confirmed, your template will be
permanently deleted.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="secondary"
disabled={isPending}
onClick={() => onOpenChange(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
variant="destructive"
loading={isPending}
onClick={async () => deleteTemplate({ templateId: id })}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,46 @@
import { useState } from 'react';
import { Trans } from '@lingui/macro';
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
import { LinkIcon } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
export type TemplateDirectLinkDialogWrapperProps = {
template: Template & { directLink?: TemplateDirectLink | null; recipients: Recipient[] };
};
export const TemplateDirectLinkDialogWrapper = ({
template,
}: TemplateDirectLinkDialogWrapperProps) => {
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
return (
<div>
<Button
variant="outline"
className="px-3"
onClick={(e) => {
e.preventDefault();
setTemplateDirectLinkOpen(true);
}}
>
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
{template.directLink ? (
<Trans>Manage Direct Link</Trans>
) : (
<Trans>Create Direct Link</Trans>
)}
</Button>
<TemplateDirectLinkDialog
template={template}
open={isTemplateDirectLinkOpen}
onOpenChange={setTemplateDirectLinkOpen}
/>
</div>
);
};

View File

@ -0,0 +1,479 @@
import { useEffect, useMemo, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
type Recipient,
RecipientRole,
type Template,
type TemplateDirectLink,
} from '@prisma/client';
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
import { Link } from 'react-router';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Switch } from '@documenso/ui/primitives/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDirectLinkDialogProps = {
template: Template & {
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
recipients: Recipient[];
};
open: boolean;
onOpenChange: (_open: boolean) => void;
};
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
export const TemplateDirectLinkDialog = ({
template,
open,
onOpenChange,
}: TemplateDirectLinkDialogProps) => {
const { toast } = useToast();
const { quota, remaining } = useLimits();
const { _ } = useLingui();
const [, copy] = useCopyToClipboard();
const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false);
const [token, setToken] = useState(template.directLink?.token ?? null);
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
const [currentStep, setCurrentStep] = useState<TemplateDirectLinkStep>(
token ? 'MANAGE' : 'ONBOARD',
);
const validDirectTemplateRecipients = useMemo(
() => template.recipients.filter((recipient) => recipient.role !== RecipientRole.CC),
[template.recipients],
);
const {
mutateAsync: createTemplateDirectLink,
isPending: isCreatingTemplateDirectLink,
reset: resetCreateTemplateDirectLink,
} = trpcReact.template.createTemplateDirectLink.useMutation({
onSuccess: (data) => {
setToken(data.token);
setIsEnabled(data.enabled);
setCurrentStep('MANAGE');
},
onError: () => {
setSelectedRecipientId(null);
toast({
title: _(msg`Something went wrong`),
description: _(msg`Unable to create direct template access. Please try again later.`),
variant: 'destructive',
});
},
});
const { mutateAsync: toggleTemplateDirectLink, isPending: isTogglingTemplateAccess } =
trpcReact.template.toggleTemplateDirectLink.useMutation({
onSuccess: (data) => {
const enabledDescription = msg`Direct link signing has been enabled`;
const disabledDescription = msg`Direct link signing has been disabled`;
toast({
title: _(msg`Success`),
description: _(data.enabled ? enabledDescription : disabledDescription),
});
},
onError: (_ctx, data) => {
const enabledDescription = msg`An error occurred while enabling direct link signing.`;
const disabledDescription = msg`An error occurred while disabling direct link signing.`;
toast({
title: _(msg`Something went wrong`),
description: _(data.enabled ? enabledDescription : disabledDescription),
variant: 'destructive',
});
},
});
const { mutateAsync: deleteTemplateDirectLink, isPending: isDeletingTemplateDirectLink } =
trpcReact.template.deleteTemplateDirectLink.useMutation({
onSuccess: () => {
onOpenChange(false);
setToken(null);
toast({
title: _(msg`Success`),
description: _(msg`Direct template link deleted`),
duration: 5000,
});
setToken(null);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We encountered an error while removing the direct template link. Please try again later.`,
),
variant: 'destructive',
});
},
});
const onCopyClick = async (token: string) =>
copy(formatDirectTemplatePath(token)).then(() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The direct link has been copied to your clipboard`),
});
});
const onRecipientTableRowClick = async (recipientId: number) => {
if (isLoading) {
return;
}
setSelectedRecipientId(recipientId);
await createTemplateDirectLink({
templateId: template.id,
directRecipientId: recipientId,
});
};
const isLoading =
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
useEffect(() => {
resetCreateTemplateDirectLink();
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
setSelectedRecipientId(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<fieldset disabled={isLoading} className="relative">
<AnimateGenericFadeInOut motionKey={currentStep}>
{match({ token, currentStep })
.with({ token: P.nullish, currentStep: 'ONBOARD' }, () => (
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Create Direct Signing Link</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Here's how it works:</Trans>
</DialogDescription>
</DialogHeader>
<ul className="mt-4 space-y-4 pl-12">
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
<li className="relative" key={index}>
<div className="absolute -left-12">
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 text-sm font-bold">
{index + 1}
</div>
</div>
<h3 className="font-semibold">{_(step.title)}</h3>
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
</li>
))}
</ul>
{remaining.directTemplates === 0 && (
<Alert variant="warning">
<AlertTitle>
<Trans>
Direct template link usage exceeded ({quota.directTemplates}/
{quota.directTemplates})
</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
You have reached the maximum limit of {quota.directTemplates} direct
templates.{' '}
<Link
className="mt-1 block underline underline-offset-4"
to="/settings/billing"
>
Upgrade your account to continue!
</Link>
</Trans>
</AlertDescription>
</Alert>
)}
{remaining.directTemplates !== 0 && (
<DialogFooter className="mx-auto mt-4">
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
<Trans> Enable direct link signing</Trans>
</Button>
</DialogFooter>
)}
</DialogContent>
))
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
<DialogContent className="relative">
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
</div>
)}
<DialogHeader>
<DialogTitle>
<Trans>Choose Direct Link Recipient</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Choose an existing recipient from below to continue</Trans>
</DialogDescription>
</DialogHeader>
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Trans>Recipient</Trans>
</TableHead>
<TableHead>
<Trans>Role</Trans>
</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{validDirectTemplateRecipients.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="h-16 text-center">
<p className="text-muted-foreground">
<Trans>No valid recipients found</Trans>
</p>
</TableCell>
</TableRow>
)}
{validDirectTemplateRecipients.map((row) => (
<TableRow
className="cursor-pointer"
key={row.id}
onClick={async () => onRecipientTableRowClick(row.id)}
>
<TableCell>
<div className="text-muted-foreground text-sm">
<p>{row.name}</p>
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
</TableCell>
<TableCell>
{selectedRecipientId === row.id ? (
<CircleDotIcon className="h-5 w-5 text-neutral-300" />
) : (
<CircleIcon className="h-5 w-5 text-neutral-300" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
{!template.recipients.some(
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
) && (
<DialogFooter className="mx-auto">
<div className="flex flex-col items-center justify-center">
{validDirectTemplateRecipients.length !== 0 && (
<p className="text-muted-foreground text-sm">
<Trans>Or</Trans>
</p>
)}
<Button
type="button"
className="mt-2"
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
onClick={async () =>
createTemplateDirectLink({
templateId: template.id,
})
}
>
<Trans>Create one automatically</Trans>
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
))
.with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>
<Trans>Direct Link Signing</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Manage the direct link signing for this template</Trans>
</DialogDescription>
</DialogHeader>
<div>
<div className="flex flex-row items-center justify-between">
<Label className="flex flex-row">
<Trans>Enable Direct Link Signing</Trans>
<Tooltip>
<TooltipTrigger tabIndex={-1} className="ml-2">
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<Trans>
Disabling direct link signing will prevent anyone from accessing the
link.
</Trans>
</TooltipContent>
</Tooltip>
</Label>
<Switch
className="mt-2"
checked={isEnabled}
onCheckedChange={(value) => setIsEnabled(value)}
/>
</div>
<div className="mt-2">
<Label htmlFor="copy-direct-link">
<Trans>Copy Shareable Link</Trans>
</Label>
<div className="relative mt-1">
<Input
id="copy-direct-link"
disabled
value={formatDirectTemplatePath(token).replace(/https?:\/\//, '')}
readOnly
className="pr-12"
/>
<div className="absolute bottom-0 right-1 top-0 flex items-center justify-center">
<Button
variant="none"
type="button"
className="h-8 w-8"
onClick={() => void onCopyClick(token)}
>
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
</Button>
</div>
</div>
</div>
</div>
<DialogFooter className="mt-4">
<Button
type="button"
variant="destructive"
className="mr-auto w-full sm:w-auto"
loading={isDeletingTemplateDirectLink}
onClick={() => setCurrentStep('CONFIRM_DELETE')}
>
<Trans>Remove</Trans>
</Button>
<Button
type="button"
loading={isTogglingTemplateAccess}
onClick={async () => {
await toggleTemplateDirectLink({
templateId: template.id,
enabled: isEnabled,
}).catch((e) => null);
onOpenChange(false);
}}
>
<Trans>Save</Trans>
</Button>
</DialogFooter>
</DialogContent>
))
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Please note that proceeding will remove direct linking recipient and turn it
into a placeholder.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setCurrentStep('MANAGE')}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
variant="destructive"
loading={isDeletingTemplateDirectLink}
onClick={() => void deleteTemplateDirectLink({ templateId: template.id })}
>
<Trans>Confirm</Trans>
</Button>
</DialogFooter>
</DialogContent>
))
.otherwise(() => null)}
</AnimateGenericFadeInOut>
</fieldset>
</Dialog>
);
};

View File

@ -0,0 +1,90 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDuplicateDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const TemplateDuplicateDialog = ({
id,
open,
onOpenChange,
}: TemplateDuplicateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: duplicateTemplate, isPending } =
trpcReact.template.duplicateTemplate.useMutation({
onSuccess: () => {
// router.refresh(); // Todo
toast({
title: _(msg`Template duplicated`),
description: _(msg`Your template has been duplicated successfully.`),
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while duplicating template.`),
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Do you want to duplicate this template?</Trans>
</DialogTitle>
<DialogDescription className="pt-2">
<Trans>Your template will be duplicated.</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
disabled={isPending}
variant="secondary"
onClick={() => onOpenChange(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
loading={isPending}
onClick={async () =>
duplicateTemplate({
templateId: id,
})
}
>
<Trans>Duplicate</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,136 @@
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateMoveDialogProps = {
templateId: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const TemplateMoveDialog = ({ templateId, open, onOpenChange }: TemplateMoveDialogProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
onSuccess: () => {
// router.refresh(); // Todo
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been successfully moved to the selected team.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (err) => {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(
AppErrorCode.NOT_FOUND,
() => msg`Template not found or already associated with a team.`,
)
.with(AppErrorCode.UNAUTHORIZED, () => msg`You are not a member of this team.`)
.otherwise(() => msg`An error occurred while moving the template.`);
toast({
title: _(msg`Error`),
description: _(errorMessage),
variant: 'destructive',
duration: 7500,
});
},
});
const onMove = async () => {
if (!selectedTeamId) {
return;
}
await moveTemplate({ templateId, teamId: selectedTeamId });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Template to Team</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a team to move this template to. This action cannot be undone.</Trans>
</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
<SelectTrigger>
<SelectValue placeholder={_(msg`Select a team`)} />
</SelectTrigger>
<SelectContent>
{isLoadingTeams ? (
<SelectItem value="loading" disabled>
<Trans>Loading teams...</Trans>
</SelectItem>
) : (
teams?.map((team) => (
<SelectItem key={team.id} value={team.id.toString()}>
<div className="flex items-center gap-4">
<Avatar className="h-8 w-8">
{team.avatarImageId && (
<AvatarImage
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
/>
)}
<AvatarFallback className="text-sm text-gray-400">
{team.name.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<span>{team.name}</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button onClick={onMove} loading={isPending} disabled={!selectedTeamId || isPending}>
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,587 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Recipient } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import { InfoIcon, Plus, Upload, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import * as z from 'zod';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
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 {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.any()
.refine((data) => data instanceof File || data === undefined)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
signingOrder: z.number().optional(),
}),
),
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, recipients] of items.recipients.entries()) {
const email = recipients.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', firstFoundIndex, 'email'],
});
}
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
export type TemplateUseDialogProps = {
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
recipients: Recipient[];
documentDistributionMethod?: DocumentDistributionMethod;
documentRootPath: string;
trigger?: React.ReactNode;
};
export function TemplateUseDialog({
recipients,
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
documentRootPath,
templateId,
templateSigningOrder,
trigger,
}: TemplateUseDialogProps) {
const { toast } = useToast();
const { _ } = useLingui();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
distributeDocument: false,
useCustomDocument: false,
customDocumentData: undefined,
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,
);
return {
id: recipient.id,
name: !isRecipientNamePlaceholder ? recipient.name : '',
email: !isRecipientEmailPlaceholder ? recipient.email : '',
signingOrder: recipient.signingOrder ?? undefined,
};
}),
},
});
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
try {
let customDocumentDataId: string | undefined = undefined;
if (data.useCustomDocument && data.customDocumentData) {
// const customDocumentData = await putPdfFile(data.customDocumentData);
// Todo
const formData = new FormData();
formData.append('file', data.customDocumentData);
const customDocumentData = await fetch('/api/file', {
method: 'POST',
body: formData,
})
.then((res) => res.json())
.catch((e) => {
console.error('Upload failed:', e);
throw new AppError('UPLOAD_FAILED');
});
customDocumentDataId = customDocumentData.id;
}
const { id } = await createDocumentFromTemplate({
templateId,
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentDataId,
});
toast({
title: _(msg`Document created`),
description: _(msg`Your document has been created from the template successfully.`),
duration: 5000,
});
let documentPath = `${documentRootPath}/${id}`;
if (
data.distributeDocument &&
documentDistributionMethod === DocumentDistributionMethod.NONE
) {
documentPath += '?action=view-signing-links';
}
await navigate(documentPath);
} catch (err) {
const error = AppError.parseError(err);
const toastPayload: Toast = {
title: _(msg`Error`),
description: _(msg`An error occurred while creating document from template.`),
variant: 'destructive',
};
if (error.code === 'DOCUMENT_SEND_FAILED') {
toastPayload.description = _(
msg`The document was created but could not be sent to recipients.`,
);
}
toast(toastPayload);
}
};
const { fields: formRecipients } = useFieldArray({
control: form.control,
name: 'recipients',
});
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" className="bg-background">
<Plus className="-ml-1 mr-2 h-4 w-4" />
<Trans>Use Template</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
<Trans>Create document from template</Trans>
</DialogTitle>
<DialogDescription>
{recipients.length === 0 ? (
<Trans>A draft document will be created</Trans>
) : (
<Trans>Add the recipients to create the document with</Trans>
)}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{formRecipients.map((recipient, index) => (
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
<FormField
control={form.control}
name={`recipients.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('w-20', {
'mt-8': index === 0,
})}
>
<FormControl>
<Input
{...field}
disabled
className="items-center justify-center"
value={
field.value?.toString() ||
recipients[index]?.signingOrder?.toString()
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`recipients.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<Input
{...field}
placeholder={recipients[index].email || _(msg`Email`)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`recipients.${index}.name`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<Input
{...field}
placeholder={recipients[index].name || _(msg`Name`)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
))}
{recipients.length > 0 && (
<div className="mt-4 flex flex-row items-center">
<FormField
control={form.control}
name="distributeDocument"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="distributeDocument"
className="h-5 w-5"
checked={field.value}
onCheckedChange={field.onChange}
/>
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="distributeDocument"
>
<Trans>Send document</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
The document will be immediately sent to recipients if this
is checked.
</Trans>
</p>
<p>
<Trans>
Otherwise, the document will be created as a draft.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
)}
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="distributeDocument"
>
<Trans>Create as pending</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
Create the document as pending and ready to sign.
</Trans>
</p>
<p>
<Trans>We won't send anything to notify recipients.</Trans>
</p>
<p className="mt-2">
<Trans>
We will generate signing links for you, which you can send
to the recipients through your method of choice.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
)}
</div>
</FormItem>
)}
/>
</div>
)}
<FormField
control={form.control}
name="useCustomDocument"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="useCustomDocument"
className="h-5 w-5"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue('customDocumentData', undefined);
}
}}
/>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="useCustomDocument"
>
<Trans>Upload custom document</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
Upload a custom document to use instead of the template's default
document
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</FormItem>
)}
/>
{form.watch('useCustomDocument') && (
<div className="my-4">
<FormField
control={form.control}
name="customDocumentData"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="w-full space-y-4">
<label
className={cn(
'text-muted-foreground hover:border-muted-foreground/50 group relative flex min-h-[150px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 px-6 py-10 transition-colors',
{
'border-destructive hover:border-destructive':
form.formState.errors.customDocumentData,
},
)}
>
<div className="text-center">
{!field.value && (
<>
<Upload className="text-muted-foreground/50 mx-auto h-10 w-10" />
<div className="mt-4 flex text-sm leading-6">
<span className="text-muted-foreground relative">
<Trans>
<span className="text-primary font-semibold">
Click to upload
</span>{' '}
or drag and drop
</Trans>
</span>
</div>
<p className="text-muted-foreground/80 text-xs">
PDF files only
</p>
</>
)}
{field.value && (
<div className="text-muted-foreground space-y-1">
<p className="text-sm font-medium">{field.value.name}</p>
<p className="text-muted-foreground/60 text-xs">
{(field.value.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
)}
</div>
<input
type="file"
className="absolute h-full w-full opacity-0"
accept=".pdf,application/pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) {
field.onChange(undefined);
return;
}
if (file.type !== 'application/pdf') {
form.setError('customDocumentData', {
type: 'manual',
message: _(msg`Please select a PDF file`),
});
return;
}
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
form.setError('customDocumentData', {
type: 'manual',
message: _(
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
),
});
return;
}
field.onChange(file);
}}
/>
{field.value && (
<div className="absolute right-2 top-2">
<Button
type="button"
variant="destructive"
className="h-6 w-6 p-0"
onClick={(e) => {
e.preventDefault();
field.onChange(undefined);
}}
>
<X className="h-4 w-4" />
<div className="sr-only">
<Trans>Clear file</Trans>
</div>
</Button>
</div>
)}
</label>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Close</Trans>
</Button>
</DialogClose>
<Button type="submit" loading={form.formState.isSubmitting}>
{!form.getValues('distributeDocument') ? (
<Trans>Create as draft</Trans>
) : documentDistributionMethod === DocumentDistributionMethod.EMAIL ? (
<Trans>Create and send</Trans>
) : (
<Trans>Create signing links</Trans>
)}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
}