diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index b7654c7cf..389528bf8 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -86,6 +86,7 @@ export const SinglePlayerClient = () => { data.fields.map((field, i) => ({ id: i, documentId: -1, + templateId: null, recipientId: -1, type: field.type, page: field.pageNumber, @@ -148,6 +149,7 @@ export const SinglePlayerClient = () => { const placeholderRecipient: Recipient = { id: -1, documentId: -1, + templateId: null, email: '', name: '', token: '', diff --git a/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx index aa423e522..1af71c775 100644 --- a/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx +++ b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx @@ -6,8 +6,9 @@ import Link from 'next/link'; import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; -import { DocumentStatus, Signature } from '@documenso/prisma/client'; -import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; +import type { Signature } from '@documenso/prisma/client'; +import { DocumentStatus } from '@documenso/prisma/client'; +import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; import DocumentDialog from '@documenso/ui/components/document/document-dialog'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; diff --git a/apps/marketing/src/components/constants.ts b/apps/marketing/src/components/constants.ts index 1f11df116..dcbb631a2 100644 --- a/apps/marketing/src/components/constants.ts +++ b/apps/marketing/src/components/constants.ts @@ -1,5 +1,5 @@ export const STEP = { EMAIL: 'EMAIL', NAME: 'NAME', - SIGN: "SIGN" + SIGN: 'SIGN', } as const; diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index 9c3532f88..b8031b088 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -99,6 +99,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = }; const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); + return ( diff --git a/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx index 7a8ff2d64..56c112d75 100644 --- a/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx @@ -41,6 +41,7 @@ export const DuplicateDocumentDialog = ({ trpcReact.document.duplicateDocument.useMutation({ onSuccess: (newId) => { router.push(`/documents/${newId}`); + toast({ title: 'Document Duplicated', description: 'Your document has been successfully duplicated.', diff --git a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx new file mode 100644 index 000000000..bdc769e79 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + DocumentFlowFormContainer, + DocumentFlowFormContainerHeader, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { Stepper } from '@documenso/ui/primitives/stepper'; +import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields'; +import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; +import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients'; +import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type EditTemplateFormProps = { + className?: string; + user: User; + template: Template; + recipients: Recipient[]; + fields: Field[]; + documentData: DocumentData; +}; + +type EditTemplateStep = 'signers' | 'fields'; +const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields']; + +export const EditTemplateForm = ({ + className, + template, + recipients, + fields, + user: _user, + documentData, +}: EditTemplateFormProps) => { + const { toast } = useToast(); + const router = useRouter(); + + const [step, setStep] = useState('signers'); + + const documentFlow: Record = { + signers: { + title: 'Add Placeholders', + description: 'Add all relevant placeholders for each recipient.', + stepIndex: 1, + }, + fields: { + title: 'Add Fields', + description: 'Add all relevant fields for each recipient.', + stepIndex: 2, + }, + }; + + const currentDocumentFlow = documentFlow[step]; + + const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation(); + const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation(); + + const onAddTemplatePlaceholderFormSubmit = async ( + data: TAddTemplatePlacholderRecipientsFormSchema, + ) => { + try { + await addTemplateSigners({ + templateId: template.id, + signers: data.signers, + }); + + router.refresh(); + + setStep('fields'); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while adding signers.', + variant: 'destructive', + }); + } + }; + + const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => { + try { + await addTemplateFields({ + templateId: template.id, + fields: data.fields, + }); + + toast({ + title: 'Template saved', + description: 'Your templates has been saved successfully.', + duration: 5000, + }); + + router.push('/templates'); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while adding signers.', + variant: 'destructive', + }); + } + }; + + return ( +
+ + + + + + +
+ e.preventDefault()} + > + + + setStep(EditTemplateSteps[step - 1])} + > + + + + + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/[id]/page.tsx b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx new file mode 100644 index 000000000..6d234eff2 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +import { ChevronLeft } from 'lucide-react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template'; +import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template'; +import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; + +import { TemplateType } from '~/components/formatter/template-type'; + +import { EditTemplateForm } from './edit-template'; + +export type TemplatePageProps = { + params: { + id: string; + }; +}; + +export default async function TemplatePage({ params }: TemplatePageProps) { + const { id } = params; + + const templateId = Number(id); + + if (!templateId || Number.isNaN(templateId)) { + redirect('/documents'); + } + + const { user } = await getRequiredServerComponentSession(); + + const template = await getTemplateById({ + id: templateId, + userId: user.id, + }).catch(() => null); + + if (!template || !template.templateDocumentData) { + redirect('/documents'); + } + + const { templateDocumentData } = template; + + const [templateRecipients, templateFields] = await Promise.all([ + getRecipientsForTemplate({ + templateId, + userId: user.id, + }), + getFieldsForTemplate({ + templateId, + userId: user.id, + }), + ]); + + return ( +
+ + + Templates + + +

+ {template.title} +

+ +
+ +
+ + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx new file mode 100644 index 000000000..9f26d632c --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState } from 'react'; + +import Link from 'next/link'; + +import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react'; +import { useSession } from 'next-auth/react'; + +import type { Template } from '@documenso/prisma/client'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; + +import { DeleteTemplateDialog } from './delete-template-dialog'; +import { DuplicateTemplateDialog } from './duplicate-template-dialog'; + +export type DataTableActionDropdownProps = { + row: Template; +}; + +export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { + const { data: session } = useSession(); + + const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); + + if (!session) { + return null; + } + + const isOwner = row.userId === session.user.id; + + return ( + + + + + + + Action + + + + + Edit + + + + {/* onDuplicateButtonClick(row.id)}> */} + setDuplicateDialogOpen(true)}> + + Duplicate + + + setDeleteDialogOpen(true)}> + + Delete + + + + + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx new file mode 100644 index 000000000..63d6888b1 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Loader, Plus } from 'lucide-react'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import type { Template } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { LocaleDate } from '~/components/formatter/locale-date'; +import { TemplateType } from '~/components/formatter/template-type'; + +import { DataTableActionDropdown } from './data-table-action-dropdown'; +import { DataTableTitle } from './data-table-title'; + +type TemplatesDataTableProps = { + templates: Template[]; + perPage: number; + page: number; + totalPages: number; +}; + +export const TemplatesDataTable = ({ + templates, + perPage, + page, + totalPages, +}: TemplatesDataTableProps) => { + const [isPending, startTransition] = useTransition(); + const updateSearchParams = useUpdateSearchParams(); + + const router = useRouter(); + + const { toast } = useToast(); + const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({}); + + const { mutateAsync: createDocumentFromTemplate } = + trpc.template.createDocumentFromTemplate.useMutation(); + + const onPaginationChange = (page: number, perPage: number) => { + startTransition(() => { + updateSearchParams({ + page, + perPage, + }); + }); + }; + + const onUseButtonClick = async (templateId: number) => { + try { + const { id } = await createDocumentFromTemplate({ + templateId, + }); + + toast({ + title: 'Document created', + description: 'Your document has been created from the template successfully.', + duration: 5000, + }); + + router.push(`/documents/${id}`); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while creating document from template.', + variant: 'destructive', + }); + } + }; + + return ( +
+ , + }, + { + header: 'Title', + cell: ({ row }) => , + }, + { + header: 'Type', + accessorKey: 'type', + cell: ({ row }) => , + }, + { + header: 'Actions', + accessorKey: 'actions', + cell: ({ row }) => { + const isRowLoading = loadingStates[row.original.id]; + + return ( +
+ + +
+ ); + }, + }, + ]} + data={templates} + perPage={perPage} + currentPage={page} + totalPages={totalPages} + onPaginationChange={onPaginationChange} + > + {(table) => } +
+ + {isPending && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-title.tsx b/apps/web/src/app/(dashboard)/templates/data-table-title.tsx new file mode 100644 index 000000000..31e1011be --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/data-table-title.tsx @@ -0,0 +1,26 @@ +import Link from 'next/link'; + +import { useSession } from 'next-auth/react'; + +import { Template } from '@documenso/prisma/client'; + +export type DataTableTitleProps = { + row: Template; +}; + +export const DataTableTitle = ({ row }: DataTableTitleProps) => { + const { data: session } = useSession(); + + if (!session) { + return null; + } + + return ( + + {row.title} + + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx new file mode 100644 index 000000000..9075f4677 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx @@ -0,0 +1,84 @@ +import { useRouter } from 'next/navigation'; + +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 DeleteTemplateDialogProps = { + id: number; + open: boolean; + onOpenChange: (_open: boolean) => void; +}; + +export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const { mutateAsync: deleteTemplate, isLoading } = trpcReact.template.deleteTemplate.useMutation({ + onSuccess: () => { + router.refresh(); + + toast({ + title: 'Template deleted', + description: 'Your template has been successfully deleted.', + duration: 5000, + }); + + onOpenChange(false); + }, + }); + + const onDeleteTemplate = async () => { + try { + await deleteTemplate({ id }); + } catch { + toast({ + title: 'Something went wrong', + description: 'This template could not be deleted at this time. Please try again.', + variant: 'destructive', + duration: 7500, + }); + } + }; + + return ( + !isLoading && onOpenChange(value)}> + + + Do you want to delete this template? + + + Please note that this action is irreversible. Once confirmed, your template will be + permanently deleted. + + + + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx new file mode 100644 index 000000000..be743ff48 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx @@ -0,0 +1,87 @@ +import { useRouter } from 'next/navigation'; + +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 DuplicateTemplateDialogProps = { + id: number; + open: boolean; + onOpenChange: (_open: boolean) => void; +}; + +export const DuplicateTemplateDialog = ({ + id, + open, + onOpenChange, +}: DuplicateTemplateDialogProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const { mutateAsync: duplicateTemplate, isLoading } = + trpcReact.template.duplicateTemplate.useMutation({ + onSuccess: () => { + router.refresh(); + + toast({ + title: 'Template duplicated', + description: 'Your template has been duplicated successfully.', + duration: 5000, + }); + + onOpenChange(false); + }, + }); + + const onDuplicate = async () => { + try { + await duplicateTemplate({ + templateId: id, + }); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while duplicating template.', + variant: 'destructive', + }); + } + }; + + return ( + !isLoading && onOpenChange(value)}> + + + Do you want to duplicate this template? + + Your template will be duplicated. + + + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/empty-state.tsx b/apps/web/src/app/(dashboard)/templates/empty-state.tsx new file mode 100644 index 000000000..b928d8a83 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/empty-state.tsx @@ -0,0 +1,17 @@ +import { Bird } from 'lucide-react'; + +export const EmptyTemplateState = () => { + return ( +
+ + +
+

We're all empty

+ +

+ You have not yet created any templates. To create a template please upload one. +

+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx new file mode 100644 index 000000000..a4aa9bce2 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx @@ -0,0 +1,228 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { FilePlus, X } from 'lucide-react'; +import { useSession } from 'next-auth/react'; +import { useForm } from 'react-hook-form'; +import * as z from 'zod'; + +import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; +import { base64 } from '@documenso/lib/universal/base64'; +import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; +import { + Form, + FormControl, + FormDescription, + 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'; + +const ZCreateTemplateFormSchema = z.object({ + name: z.string(), +}); + +type TCreateTemplateFormSchema = z.infer; + +export const NewTemplateDialog = () => { + const router = useRouter(); + const { data: session } = useSession(); + const { toast } = useToast(); + + const form = useForm({ + defaultValues: { + name: '', + }, + resolver: zodResolver(ZCreateTemplateFormSchema), + }); + + const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } = + trpc.template.createTemplate.useMutation(); + + const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false); + const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>(); + + const onFileDrop = async (file: File) => { + try { + const arrayBuffer = await file.arrayBuffer(); + const base64String = base64.encode(new Uint8Array(arrayBuffer)); + + setUploadedFile({ + file, + fileBase64: `data:application/pdf;base64,${base64String}`, + }); + + if (!form.getValues('name')) { + form.setValue('name', file.name); + } + } catch { + toast({ + title: 'Something went wrong', + description: 'Please try again later.', + variant: 'destructive', + }); + } + }; + + const onSubmit = async (values: TCreateTemplateFormSchema) => { + if (!uploadedFile) { + return; + } + + const file: File = uploadedFile.file; + + try { + const { type, data } = await putFile(file); + + const { id: templateDocumentDataId } = await createDocumentData({ + type, + data, + }); + + const { id } = await createTemplate({ + title: values.name ? values.name : file.name, + templateDocumentDataId, + }); + + toast({ + title: 'Template document uploaded', + description: + 'Your document has been uploaded successfully. You will be redirected to the template page.', + duration: 5000, + }); + + setShowNewTemplateDialog(false); + + void router.push(`/templates/${id}`); + } catch { + toast({ + title: 'Something went wrong', + description: 'Please try again later.', + variant: 'destructive', + }); + } + }; + + const resetForm = () => { + if (form.getValues('name') === uploadedFile?.file.name) { + form.reset(); + } + + setUploadedFile(null); + }; + + useEffect(() => { + if (!showNewTemplateDialog) { + form.reset(); + } + }, [form, showNewTemplateDialog]); + + return ( + + + + + + + + New Template + + +
+
+ + ( + + Name your template + + + + + + Leave this empty if you would like to use your document's name for the + template + + + + + )} + /> + +
+ + +
+ {uploadedFile ? ( + + + + +
+
+
+
+
+ +

+ Uploaded Document +

+ + + {uploadedFile.file.name} + + + + ) : ( + + )} +
+
+ +
+ +
+ + +
+ +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/page.tsx b/apps/web/src/app/(dashboard)/templates/page.tsx new file mode 100644 index 000000000..f4167e42a --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/page.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTemplates } from '@documenso/lib/server-only/template/get-templates'; + +import { TemplatesDataTable } from './data-table-templates'; +import { EmptyTemplateState } from './empty-state'; +import { NewTemplateDialog } from './new-template-dialog'; + +type TemplatesPageProps = { + searchParams?: { + page?: number; + perPage?: number; + }; +}; + +export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) { + const { user } = await getRequiredServerComponentSession(); + const page = Number(searchParams.page) || 1; + const perPage = Number(searchParams.perPage) || 10; + + const { templates, totalPages } = await getTemplates({ + userId: user.id, + page: page, + perPage: perPage, + }); + + return ( +
+
+

Templates

+ +
+ +
+
+ +
+ {templates.length > 0 ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index 76077cb04..e04bc2818 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -3,6 +3,9 @@ import type { HTMLAttributes } from 'react'; import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + import { Search } from 'lucide-react'; import { cn } from '@documenso/ui/lib/utils'; @@ -10,10 +13,22 @@ import { Button } from '@documenso/ui/primitives/button'; import { CommandMenu } from '../common/command-menu'; +const navigationLinks = [ + { + href: '/documents', + label: 'Documents', + }, + { + href: '/templates', + label: 'Templates', + }, +]; + export type DesktopNavProps = HTMLAttributes; export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { - // const pathname = usePathname(); + const pathname = usePathname(); + const [open, setOpen] = useState(false); const [modifierKey, setModifierKey] = useState(() => 'Ctrl'); @@ -26,9 +41,29 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { return ( - - {/* 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) => { -
+
{/* + + + + + + + + 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} + + )} + + ))} + + + + + )} + +
+
+ + + + + + + +
+
+
+ + + + + + { + 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) => ( + +
+ + + +
+ +
+ + + +
+ +
+ +
+ +
+ + +
+
+ ))} +
+
+ + + +
+ +
+
+ + + + + 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 +>;