diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 60b385403..3471f4f88 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -10,7 +10,13 @@
"ghcr.io/devcontainers/features/node:1": {}
},
"onCreateCommand": "./.devcontainer/on-create.sh",
- "forwardPorts": [3000, 54320, 9000, 2500, 1100],
+ "forwardPorts": [
+ 3000,
+ 54320,
+ 9000,
+ 2500,
+ 1100
+ ],
"customizations": {
"vscode": {
"extensions": [
@@ -25,8 +31,8 @@
"GitHub.copilot",
"GitHub.vscode-pull-request-github",
"Prisma.prisma",
- "VisualStudioExptTeam.vscodeintellicode",
+ "VisualStudioExptTeam.vscodeintellicode"
]
}
}
-}
+}
\ No newline at end of file
diff --git a/apps/marketing/src/app/(marketing)/[content]/page.tsx b/apps/marketing/src/app/(marketing)/[content]/page.tsx
index ba23e6b81..72941fbc5 100644
--- a/apps/marketing/src/app/(marketing)/[content]/page.tsx
+++ b/apps/marketing/src/app/(marketing)/[content]/page.tsx
@@ -5,11 +5,10 @@ import { allDocuments } from 'contentlayer/generated';
import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks';
-export const generateStaticParams = () =>
- allDocuments.map((post) => ({ post: post._raw.flattenedPath }));
+export const dynamic = 'force-dynamic';
export const generateMetadata = ({ params }: { params: { content: string } }) => {
- const document = allDocuments.find((post) => post._raw.flattenedPath === params.content);
+ const document = allDocuments.find((doc) => doc._raw.flattenedPath === params.content);
if (!document) {
return { title: 'Not Found' };
diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx
index 495b8946e..14b8b2d8f 100644
--- a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx
+++ b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx
@@ -9,9 +9,6 @@ import { useMDXComponent } from 'next-contentlayer/hooks';
export const dynamic = 'force-dynamic';
-export const generateStaticParams = () =>
- allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
-
export const generateMetadata = ({ params }: { params: { post: string } }) => {
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
diff --git a/apps/marketing/src/app/(marketing)/blog/page.tsx b/apps/marketing/src/app/(marketing)/blog/page.tsx
index 2eac963d1..4be1ab694 100644
--- a/apps/marketing/src/app/(marketing)/blog/page.tsx
+++ b/apps/marketing/src/app/(marketing)/blog/page.tsx
@@ -5,6 +5,7 @@ import { allBlogPosts } from 'contentlayer/generated';
export const metadata: Metadata = {
title: 'Blog',
};
+
export default function BlogPage() {
const blogPosts = allBlogPosts.sort((a, b) => {
const dateA = new Date(a.date);
diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx
index e0b55dbf5..9f1ebb289 100644
--- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx
+++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx
@@ -256,6 +256,7 @@ export const SinglePlayerClient = () => {
fields={fields}
onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
+ requireCustomText={Boolean(fields.find((field) => field.type === 'TEXT'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/>
diff --git a/apps/marketing/src/app/layout.tsx b/apps/marketing/src/app/layout.tsx
index 57da42c3f..99a1a6483 100644
--- a/apps/marketing/src/app/layout.tsx
+++ b/apps/marketing/src/app/layout.tsx
@@ -2,6 +2,8 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
+import { PublicEnvScript } from 'next-runtime-env';
+
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
@@ -62,6 +64,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
+
diff --git a/apps/marketing/src/components/(marketing)/pricing-table.tsx b/apps/marketing/src/components/(marketing)/pricing-table.tsx
index 0d9956d86..bdaa9fdf4 100644
--- a/apps/marketing/src/components/(marketing)/pricing-table.tsx
+++ b/apps/marketing/src/components/(marketing)/pricing-table.tsx
@@ -114,7 +114,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
diff --git a/apps/web/package.json b/apps/web/package.json
index fd4faa0c1..efd524992 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -14,6 +14,7 @@
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
},
"dependencies": {
+ "@documenso/api": "*",
"@documenso/assets": "*",
"@documenso/ee": "*",
"@documenso/lib": "*",
@@ -42,6 +43,7 @@
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
+ "remeda": "^1.27.1",
"sharp": "0.33.1",
"ts-pattern": "^5.0.5",
"typescript": "5.2.2",
diff --git a/apps/web/src/app/(dashboard)/admin/nav.tsx b/apps/web/src/app/(dashboard)/admin/nav.tsx
index 089861069..b0d652283 100644
--- a/apps/web/src/app/(dashboard)/admin/nav.tsx
+++ b/apps/web/src/app/(dashboard)/admin/nav.tsx
@@ -1,11 +1,11 @@
'use client';
-import { HTMLAttributes } from 'react';
+import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-import { BarChart3, FileStack, User2, Wallet2 } from 'lucide-react';
+import { BarChart3, FileStack, Settings, User2, Wallet2 } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -78,6 +78,20 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
Subscriptions
+
+
);
};
diff --git a/apps/web/src/app/(dashboard)/admin/site-settings/banner-form.tsx b/apps/web/src/app/(dashboard)/admin/site-settings/banner-form.tsx
new file mode 100644
index 000000000..351e146ff
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/admin/site-settings/banner-form.tsx
@@ -0,0 +1,200 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import type { z } from 'zod';
+
+import type { TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
+import {
+ SITE_SETTINGS_BANNER_ID,
+ ZSiteSettingsBannerSchema,
+} from '@documenso/lib/server-only/site-settings/schemas/banner';
+import { TRPCClientError } from '@documenso/trpc/client';
+import { trpc as trpcReact } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import { ColorPicker } from '@documenso/ui/primitives/color-picker';
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Switch } from '@documenso/ui/primitives/switch';
+import { Textarea } from '@documenso/ui/primitives/textarea';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+const ZBannerFormSchema = ZSiteSettingsBannerSchema;
+
+type TBannerFormSchema = z.infer;
+
+export type BannerFormProps = {
+ banner?: TSiteSettingsBannerSchema;
+};
+
+export function BannerForm({ banner }: BannerFormProps) {
+ const router = useRouter();
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(ZBannerFormSchema),
+ defaultValues: {
+ id: SITE_SETTINGS_BANNER_ID,
+ enabled: banner?.enabled ?? false,
+ data: {
+ content: banner?.data?.content ?? '',
+ bgColor: banner?.data?.bgColor ?? '#000000',
+ textColor: banner?.data?.textColor ?? '#FFFFFF',
+ },
+ },
+ });
+
+ const enabled = form.watch('enabled');
+
+ const { mutateAsync: updateSiteSetting, isLoading: isUpdateSiteSettingLoading } =
+ trpcReact.admin.updateSiteSetting.useMutation();
+
+ const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
+ try {
+ await updateSiteSetting({
+ id,
+ enabled,
+ data,
+ });
+
+ toast({
+ title: 'Banner Updated',
+ description: 'Your banner has been updated successfully.',
+ duration: 5000,
+ });
+
+ router.refresh();
+ } catch (err) {
+ if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
+ toast({
+ title: 'An error occurred',
+ description: err.message,
+ variant: 'destructive',
+ });
+ } else {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to update the banner. Please try again later.',
+ });
+ }
+ }
+ };
+
+ return (
+
+
Site Banner
+
+ The site banner is a message that is shown at the top of the site. It can be used to display
+ important information to your users.
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/admin/site-settings/page.tsx b/apps/web/src/app/(dashboard)/admin/site-settings/page.tsx
new file mode 100644
index 000000000..bffb72ff0
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/admin/site-settings/page.tsx
@@ -0,0 +1,24 @@
+import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
+import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
+
+import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
+
+import { BannerForm } from './banner-form';
+
+// import { BannerForm } from './banner-form';
+
+export default async function AdminBannerPage() {
+ const banner = await getSiteSettings().then((settings) =>
+ settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
+ );
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx
new file mode 100644
index 000000000..334089a5f
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import Link from 'next/link';
+
+import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
+import { useSession } from 'next-auth/react';
+import { match } from 'ts-pattern';
+
+import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
+import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
+import { trpc as trpcClient } from '@documenso/trpc/client';
+import { Button } from '@documenso/ui/primitives/button';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type DocumentPageViewButtonProps = {
+ document: Document & {
+ User: Pick;
+ Recipient: Recipient[];
+ team: Pick | null;
+ };
+ team?: Pick;
+};
+
+export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => {
+ const { data: session } = useSession();
+ const { toast } = useToast();
+
+ if (!session) {
+ return null;
+ }
+
+ const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email);
+
+ const isRecipient = !!recipient;
+ const isPending = document.status === DocumentStatus.PENDING;
+ const isComplete = document.status === DocumentStatus.COMPLETED;
+ const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
+ const role = recipient?.role;
+
+ const documentsPath = formatDocumentsPath(document.team?.url);
+
+ const onDownloadClick = async () => {
+ try {
+ const documentWithData = await trpcClient.document.getDocumentById.query({
+ id: document.id,
+ teamId: team?.id,
+ });
+
+ const documentData = documentWithData?.documentData;
+
+ if (!documentData) {
+ throw new Error('No document available');
+ }
+
+ await downloadPDF({ documentData, fileName: documentWithData.title });
+ } catch (err) {
+ toast({
+ title: 'Something went wrong',
+ description: 'An error occurred while downloading your document.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return match({
+ isRecipient,
+ isPending,
+ isComplete,
+ isSigned,
+ })
+ .with({ isRecipient: true, isPending: true, isSigned: false }, () => (
+
+ ))
+ .with({ isComplete: false }, () => (
+
+ ))
+ .with({ isComplete: true }, () => (
+
+ ))
+ .otherwise(() => null);
+};
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx
new file mode 100644
index 000000000..3e108aed5
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx
@@ -0,0 +1,160 @@
+'use client';
+
+import { useState } from 'react';
+
+import Link from 'next/link';
+
+import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react';
+import { useSession } from 'next-auth/react';
+
+import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import { DocumentStatus } from '@documenso/prisma/client';
+import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
+import { trpc as trpcClient } from '@documenso/trpc/client';
+import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from '@documenso/ui/primitives/dropdown-menu';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { ResendDocumentActionItem } from '../_action-items/resend-document';
+import { DeleteDocumentDialog } from '../delete-document-dialog';
+import { DuplicateDocumentDialog } from '../duplicate-document-dialog';
+
+export type DocumentPageViewDropdownProps = {
+ document: Document & {
+ User: Pick;
+ Recipient: Recipient[];
+ team: Pick | null;
+ };
+ team?: Pick;
+};
+
+export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
+ const { data: session } = useSession();
+ const { toast } = useToast();
+
+ const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
+
+ if (!session) {
+ return null;
+ }
+
+ const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email);
+
+ const isOwner = document.User.id === session.user.id;
+ const isDraft = document.status === DocumentStatus.DRAFT;
+ const isComplete = document.status === DocumentStatus.COMPLETED;
+ const isDocumentDeletable = isOwner;
+ const isCurrentTeamDocument = team && document.team?.url === team.url;
+
+ const documentsPath = formatDocumentsPath(team?.url);
+
+ const onDownloadClick = async () => {
+ try {
+ const documentWithData = await trpcClient.document.getDocumentById.query({
+ id: document.id,
+ teamId: team?.id,
+ });
+
+ const documentData = documentWithData?.documentData;
+
+ if (!documentData) {
+ return;
+ }
+
+ await downloadPDF({ documentData, fileName: document.title });
+ } catch (err) {
+ toast({
+ title: 'Something went wrong',
+ description: 'An error occurred while downloading your document.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const nonSignedRecipients = document.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
+
+ return (
+
+
+
+
+
+
+ Action
+
+ {(isOwner || isCurrentTeamDocument) && !isComplete && (
+
+
+
+ Edit
+
+
+ )}
+
+ {isComplete && (
+
+
+ Download
+
+ )}
+
+ setDuplicateDialogOpen(true)}>
+
+ Duplicate
+
+
+ setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
+
+ Delete
+
+
+ Share
+
+
+
+ (
+ e.preventDefault()}>
+
+ {loading ? : }
+ Share Signing Card
+
+
+ )}
+ />
+
+
+ {isDocumentDeletable && (
+
+ )}
+ {isDuplicateDialogOpen && (
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx
new file mode 100644
index 000000000..24a85bacc
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { useMemo } from 'react';
+
+import { DateTime } from 'luxon';
+
+import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
+import { useLocale } from '@documenso/lib/client-only/providers/locale';
+import type { Document, Recipient, User } from '@documenso/prisma/client';
+
+export type DocumentPageViewInformationProps = {
+ userId: number;
+ document: Document & {
+ User: Pick;
+ Recipient: Recipient[];
+ };
+};
+
+export const DocumentPageViewInformation = ({
+ document,
+ userId,
+}: DocumentPageViewInformationProps) => {
+ const isMounted = useIsMounted();
+
+ const { locale } = useLocale();
+
+ const documentInformation = useMemo(() => {
+ let createdValue = DateTime.fromJSDate(document.createdAt).toFormat('MMMM d, yyyy');
+ let lastModifiedValue = DateTime.fromJSDate(document.updatedAt).toRelative();
+
+ if (!isMounted) {
+ createdValue = DateTime.fromJSDate(document.createdAt)
+ .setLocale(locale)
+ .toFormat('MMMM d, yyyy');
+
+ lastModifiedValue = DateTime.fromJSDate(document.updatedAt).setLocale(locale).toRelative();
+ }
+
+ return [
+ {
+ description: 'Uploaded by',
+ value: userId === document.userId ? 'You' : document.User.name ?? document.User.email,
+ },
+ {
+ description: 'Created',
+ value: createdValue,
+ },
+ {
+ description: 'Last modified',
+ value: lastModifiedValue,
+ },
+ ];
+ }, [isMounted, document, locale, userId]);
+
+ return (
+
+ Information
+
+
+ {documentInformation.map((item) => (
+ -
+ {item.description}
+ {item.value}
+
+ ))}
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx
new file mode 100644
index 000000000..1c632355a
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx
@@ -0,0 +1,153 @@
+'use client';
+
+import { useMemo } from 'react';
+
+import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
+import { DateTime } from 'luxon';
+import { match } from 'ts-pattern';
+
+import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
+import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
+import { trpc } from '@documenso/trpc/react';
+import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
+import { cn } from '@documenso/ui/lib/utils';
+
+export type DocumentPageViewRecentActivityProps = {
+ documentId: number;
+ userId: number;
+};
+
+export const DocumentPageViewRecentActivity = ({
+ documentId,
+ userId,
+}: DocumentPageViewRecentActivityProps) => {
+ const {
+ data,
+ isLoading,
+ isLoadingError,
+ refetch,
+ hasNextPage,
+ fetchNextPage,
+ isFetchingNextPage,
+ } = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
+ {
+ documentId,
+ filterForRecentActivity: true,
+ orderBy: {
+ column: 'createdAt',
+ direction: 'asc',
+ },
+ perPage: 10,
+ },
+ {
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ },
+ );
+
+ const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
+
+ return (
+
+
+
Recent activity
+
+ {/* Can add dropdown menu here for additional options. */}
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ {isLoadingError && (
+
+
Unable to load document history
+
+
+ )}
+
+
+ {data && (
+
+ )}
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx
new file mode 100644
index 000000000..37d2cd35e
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx
@@ -0,0 +1,115 @@
+import Link from 'next/link';
+
+import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
+import { match } from 'ts-pattern';
+
+import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
+import type { Document, Recipient } from '@documenso/prisma/client';
+import { SignatureIcon } from '@documenso/ui/icons/signature';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+import { Badge } from '@documenso/ui/primitives/badge';
+
+export type DocumentPageViewRecipientsProps = {
+ document: Document & {
+ Recipient: Recipient[];
+ };
+ documentRootPath: string;
+};
+
+export const DocumentPageViewRecipients = ({
+ document,
+ documentRootPath,
+}: DocumentPageViewRecipientsProps) => {
+ const recipients = document.Recipient;
+
+ return (
+
+
+
Recipients
+
+ {document.status !== DocumentStatus.COMPLETED && (
+
+ {recipients.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+ {recipients.length === 0 && (
+ - No recipients
+ )}
+
+ {recipients.map((recipient) => (
+ -
+ {recipient.email}}
+ secondaryText={
+
+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
+
+ }
+ />
+
+ {document.status !== DocumentStatus.DRAFT &&
+ recipient.signingStatus === SigningStatus.SIGNED && (
+
+ {match(recipient.role)
+ .with(RecipientRole.APPROVER, () => (
+ <>
+
+ Approved
+ >
+ ))
+ .with(RecipientRole.CC, () =>
+ document.status === DocumentStatus.COMPLETED ? (
+ <>
+
+ Sent
+ >
+ ) : (
+ <>
+
+ Ready
+ >
+ ),
+ )
+
+ .with(RecipientRole.SIGNER, () => (
+ <>
+
+ Signed
+ >
+ ))
+ .with(RecipientRole.VIEWER, () => (
+ <>
+
+ Viewed
+ >
+ ))
+ .exhaustive()}
+
+ )}
+
+ {document.status !== DocumentStatus.DRAFT &&
+ recipient.signingStatus === SigningStatus.NOT_SIGNED && (
+
+
+ Pending
+
+ )}
+
+ ))}
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx
index 6759d91ac..e20c88a27 100644
--- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx
+++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx
@@ -1,22 +1,34 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
-import { ChevronLeft, Users2 } from 'lucide-react';
+import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
+import { match } from 'ts-pattern';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
-import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
+import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import { DocumentStatus } from '@documenso/prisma/client';
import type { Team } from '@documenso/prisma/client';
-import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
+import { Button } from '@documenso/ui/primitives/button';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
-import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
-import { DocumentStatus } from '~/components/formatter/document-status';
+import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
+import {
+ DocumentStatus as DocumentStatusComponent,
+ FRIENDLY_STATUS_MAP,
+} from '~/components/formatter/document-status';
+
+import { DocumentPageViewButton } from './document-page-view-button';
+import { DocumentPageViewDropdown } from './document-page-view-dropdown';
+import { DocumentPageViewInformation } from './document-page-view-information';
+import { DocumentPageViewRecentActivity } from './document-page-view-recent-activity';
+import { DocumentPageViewRecipients } from './document-page-view-recipients';
export type DocumentPageViewProps = {
params: {
@@ -44,6 +56,10 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
teamId: team?.id,
}).catch(() => null);
+ const isDocumentHistoryEnabled = await getServerComponentFlag(
+ 'app_document_page_view_history_sheet',
+ );
+
if (!document || !document.documentData) {
redirect(documentRootPath);
}
@@ -67,16 +83,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
documentMeta.password = securePassword;
}
- const [recipients, fields] = await Promise.all([
- getRecipientsForDocument({
- documentId,
- userId: user.id,
- }),
- getFieldsForDocument({
- documentId,
- userId: user.id,
- }),
- ]);
+ const recipients = await getRecipientsForDocument({
+ documentId,
+ teamId: team?.id,
+ userId: user.id,
+ });
+
+ const documentWithRecipients = {
+ ...document,
+ Recipient: recipients,
+ };
return (
@@ -85,47 +101,105 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
Documents
-
- {document.title}
-
+
+
+
+ {document.title}
+
-
-
+
+
- {recipients.length > 0 && (
-
-
+ {recipients.length > 0 && (
+
+
-
- {recipients.length} Recipient(s)
-
+
+ {recipients.length} Recipient(s)
+
+
+ )}
+
+
+
+ {isDocumentHistoryEnabled && (
+
+
+
+
)}
- {document.status !== InternalDocumentStatus.COMPLETED && (
-
- )}
+
+
+
+
+
+
- {document.status === InternalDocumentStatus.COMPLETED && (
-
-
+
+
+
+
+
+ Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
+
+
+
+
+
+
+ {match(document.status)
+ .with(
+ DocumentStatus.COMPLETED,
+ () => 'This document has been signed by all recipients',
+ )
+ .with(
+ DocumentStatus.DRAFT,
+ () => 'This document is currently a draft and has not been sent',
+ )
+ .with(DocumentStatus.PENDING, () => {
+ const pendingRecipients = recipients.filter(
+ (recipient) => recipient.signingStatus === 'NOT_SIGNED',
+ );
+
+ return `Waiting on ${pendingRecipients.length} recipient${
+ pendingRecipients.length > 1 ? 's' : ''
+ }`;
+ })
+ .exhaustive()}
+
+
+
+
+
+
+
+ {/* Document information section. */}
+
+
+ {/* Recipients section. */}
+
+
+ {/* Recent activity section. */}
+
+
- )}
+
);
};
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
index 813458062..5d9fe78aa 100644
--- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
+++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
@@ -2,10 +2,16 @@
import { useState } from 'react';
-import { useRouter } from 'next/navigation';
+import { useRouter, useSearchParams } from 'next/navigation';
-import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client';
-import { DocumentStatus } from '@documenso/prisma/client';
+import {
+ type DocumentData,
+ type DocumentMeta,
+ DocumentStatus,
+ type Field,
+ type Recipient,
+ type User,
+} from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -24,6 +30,8 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
+import { useOptionalCurrentTeam } from '~/providers/team';
+
export type EditDocumentFormProps = {
className?: string;
user: User;
@@ -49,12 +57,10 @@ export const EditDocumentForm = ({
documentRootPath,
}: EditDocumentFormProps) => {
const { toast } = useToast();
- const router = useRouter();
- // controlled stepper state
- const [step, setStep] = useState
(
- document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
- );
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const team = useOptionalCurrentTeam();
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
@@ -86,11 +92,30 @@ export const EditDocumentForm = ({
},
};
+ const [step, setStep] = useState(() => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined;
+
+ let initialStep: EditDocumentStep =
+ document.status === DocumentStatus.DRAFT ? 'title' : 'signers';
+
+ if (
+ searchParamStep &&
+ documentFlow[searchParamStep] !== undefined &&
+ !(recipients.length === 0 && (searchParamStep === 'subject' || searchParamStep === 'fields'))
+ ) {
+ initialStep = searchParamStep;
+ }
+
+ return initialStep;
+ });
+
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
try {
// Custom invocation server action
await addTitle({
documentId: document.id,
+ teamId: team?.id,
title: data.title,
});
@@ -113,6 +138,7 @@ export const EditDocumentForm = ({
// Custom invocation server action
await addSigners({
documentId: document.id,
+ teamId: team?.id,
signers: data.signers,
});
@@ -156,6 +182,7 @@ export const EditDocumentForm = ({
try {
await sendDocument({
documentId: document.id,
+ teamId: team?.id,
meta: {
subject,
message,
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx
new file mode 100644
index 000000000..69122312e
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx
@@ -0,0 +1,122 @@
+import Link from 'next/link';
+import { redirect } from 'next/navigation';
+
+import { ChevronLeft, Users2 } from 'lucide-react';
+
+import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
+import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
+import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
+import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import type { Team } from '@documenso/prisma/client';
+import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
+
+import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
+import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
+import { DocumentStatus } from '~/components/formatter/document-status';
+
+export type DocumentEditPageViewProps = {
+ params: {
+ id: string;
+ };
+ team?: Team;
+};
+
+export const DocumentEditPageView = async ({ params, team }: DocumentEditPageViewProps) => {
+ const { id } = params;
+
+ const documentId = Number(id);
+
+ const documentRootPath = formatDocumentsPath(team?.url);
+
+ if (!documentId || Number.isNaN(documentId)) {
+ redirect(documentRootPath);
+ }
+
+ const { user } = await getRequiredServerComponentSession();
+
+ const document = await getDocumentById({
+ id: documentId,
+ userId: user.id,
+ teamId: team?.id,
+ }).catch(() => null);
+
+ if (!document || !document.documentData) {
+ redirect(documentRootPath);
+ }
+
+ if (document.status === InternalDocumentStatus.COMPLETED) {
+ redirect(`${documentRootPath}/${documentId}`);
+ }
+
+ const { documentData, documentMeta } = document;
+
+ if (documentMeta?.password) {
+ const key = DOCUMENSO_ENCRYPTION_KEY;
+
+ if (!key) {
+ throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
+ }
+
+ const securePassword = Buffer.from(
+ symmetricDecrypt({
+ key,
+ data: documentMeta.password,
+ }),
+ ).toString('utf-8');
+
+ documentMeta.password = securePassword;
+ }
+
+ const [recipients, fields] = await Promise.all([
+ getRecipientsForDocument({
+ documentId,
+ userId: user.id,
+ teamId: team?.id,
+ }),
+ getFieldsForDocument({
+ documentId,
+ userId: user.id,
+ }),
+ ]);
+
+ return (
+
+
+
+ Documents
+
+
+
+ {document.title}
+
+
+
+
+
+ {recipients.length > 0 && (
+
+
+
+
+ {recipients.length} Recipient(s)
+
+
+ )}
+
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx
new file mode 100644
index 000000000..6c613a287
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx
@@ -0,0 +1,11 @@
+import { DocumentEditPageView } from './document-edit-page-view';
+
+export type DocumentPageProps = {
+ params: {
+ id: string;
+ };
+};
+
+export default function DocumentEditPage({ params }: DocumentPageProps) {
+ return ;
+}
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-data-table.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-data-table.tsx
new file mode 100644
index 000000000..bdfdc8658
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-data-table.tsx
@@ -0,0 +1,165 @@
+'use client';
+
+import { useSearchParams } from 'next/navigation';
+
+import { DateTime } from 'luxon';
+import type { DateTimeFormatOptions } from 'luxon';
+import { UAParser } from 'ua-parser-js';
+
+import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
+import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
+import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
+import { trpc } from '@documenso/trpc/react';
+import { DataTable } from '@documenso/ui/primitives/data-table';
+import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
+import { Skeleton } from '@documenso/ui/primitives/skeleton';
+import { TableCell } from '@documenso/ui/primitives/table';
+
+import { LocaleDate } from '~/components/formatter/locale-date';
+
+export type DocumentLogsDataTableProps = {
+ documentId: number;
+};
+
+const dateFormat: DateTimeFormatOptions = {
+ ...DateTime.DATETIME_SHORT,
+ hourCycle: 'h12',
+};
+
+export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => {
+ const parser = new UAParser();
+
+ const searchParams = useSearchParams();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
+ Object.fromEntries(searchParams ?? []),
+ );
+
+ const { data, isLoading, isInitialLoading, isLoadingError } =
+ trpc.document.findDocumentAuditLogs.useQuery(
+ {
+ documentId,
+ page: parsedSearchParams.page,
+ perPage: parsedSearchParams.perPage,
+ },
+ {
+ keepPreviousData: true,
+ },
+ );
+
+ const onPaginationChange = (page: number, perPage: number) => {
+ updateSearchParams({
+ page,
+ perPage,
+ });
+ };
+
+ const uppercaseFistLetter = (text: string) => {
+ return text.charAt(0).toUpperCase() + text.slice(1);
+ };
+
+ const results = data ?? {
+ data: [],
+ perPage: 10,
+ currentPage: 1,
+ totalPages: 1,
+ };
+
+ return (
+ ,
+ },
+ {
+ header: 'User',
+ accessorKey: 'name',
+ cell: ({ row }) =>
+ row.original.name || row.original.email ? (
+
+ {row.original.name && (
+
+ {row.original.name}
+
+ )}
+
+ {row.original.email && (
+
+ {row.original.email}
+
+ )}
+
+ ) : (
+ N/A
+ ),
+ },
+ {
+ header: 'Action',
+ accessorKey: 'type',
+ cell: ({ row }) => (
+
+ {uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}
+
+ ),
+ },
+ {
+ header: 'IP Address',
+ accessorKey: 'ipAddress',
+ },
+ {
+ header: 'Browser',
+ cell: ({ row }) => {
+ if (!row.original.userAgent) {
+ return 'N/A';
+ }
+
+ parser.setUA(row.original.userAgent);
+
+ const result = parser.getResult();
+
+ return result.browser.name ?? 'N/A';
+ },
+ },
+ ]}
+ data={results.data}
+ perPage={results.perPage}
+ currentPage={results.currentPage}
+ totalPages={results.totalPages}
+ onPaginationChange={onPaginationChange}
+ error={{
+ enable: isLoadingError,
+ }}
+ skeleton={{
+ enable: isLoading && isInitialLoading,
+ rows: 3,
+ component: (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ }}
+ >
+ {(table) => }
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx
new file mode 100644
index 000000000..019ced57e
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx
@@ -0,0 +1,151 @@
+import Link from 'next/link';
+import { redirect } from 'next/navigation';
+
+import { ChevronLeft, DownloadIcon } from 'lucide-react';
+
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
+import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import type { Recipient, Team } from '@documenso/prisma/client';
+import { Button } from '@documenso/ui/primitives/button';
+import { Card } from '@documenso/ui/primitives/card';
+
+import { FRIENDLY_STATUS_MAP } from '~/components/formatter/document-status';
+
+import { DocumentLogsDataTable } from './document-logs-data-table';
+
+export type DocumentLogsPageViewProps = {
+ params: {
+ id: string;
+ };
+ team?: Team;
+};
+
+export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
+ const { id } = params;
+
+ const documentId = Number(id);
+
+ const documentRootPath = formatDocumentsPath(team?.url);
+
+ if (!documentId || Number.isNaN(documentId)) {
+ redirect(documentRootPath);
+ }
+
+ const { user } = await getRequiredServerComponentSession();
+
+ const [document, recipients] = await Promise.all([
+ getDocumentById({
+ id: documentId,
+ userId: user.id,
+ teamId: team?.id,
+ }).catch(() => null),
+ getRecipientsForDocument({
+ documentId,
+ userId: user.id,
+ teamId: team?.id,
+ }),
+ ]);
+
+ if (!document || !document.documentData) {
+ redirect(documentRootPath);
+ }
+
+ const documentInformation: { description: string; value: string }[] = [
+ {
+ description: 'Document title',
+ value: document.title,
+ },
+ {
+ description: 'Document ID',
+ value: document.id.toString(),
+ },
+ {
+ description: 'Document status',
+ value: FRIENDLY_STATUS_MAP[document.status].label,
+ },
+ {
+ description: 'Created by',
+ value: document.User.name ?? document.User.email,
+ },
+ {
+ description: 'Date created',
+ value: document.createdAt.toISOString(),
+ },
+ {
+ description: 'Last updated',
+ value: document.updatedAt.toISOString(),
+ },
+ {
+ description: 'Time zone',
+ value: document.documentMeta?.timezone ?? 'N/A',
+ },
+ ];
+
+ const formatRecipientText = (recipient: Recipient) => {
+ let text = recipient.email;
+
+ if (recipient.name) {
+ text = `${recipient.name} (${recipient.email})`;
+ }
+
+ return `${text} - ${recipient.role}`;
+ };
+
+ return (
+
+
+
+ Document
+
+
+
+
+ {document.title}
+
+
+
+
+
+
+
+
+
+
+
+ {documentInformation.map((info, i) => (
+
+
{info.description}
+
{info.value}
+
+ ))}
+
+
+
Recipients
+
+ {recipients.map((recipient) => (
+ -
+ {formatRecipientText(recipient)}
+
+ ))}
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx
new file mode 100644
index 000000000..e21f8459b
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx
@@ -0,0 +1,11 @@
+import { DocumentLogsPageView } from './document-logs-page-view';
+
+export type DocumentsLogsPageProps = {
+ params: {
+ id: string;
+ };
+};
+
+export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
+ return ;
+}
diff --git a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx
index 4bcb25a6c..5bfd85645 100644
--- a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx
+++ b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx
@@ -118,7 +118,7 @@ export const ResendDocumentActionItem = ({
-
+
Who do you want to remind?
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx
index 78ffd0b3b..455f50be5 100644
--- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx
+++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx
@@ -94,7 +94,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
() => (
diff --git a/packages/ui/primitives/document-flow/add-signature.tsx b/packages/ui/primitives/document-flow/add-signature.tsx
index 5accdca16..f1ebc885e 100644
--- a/packages/ui/primitives/document-flow/add-signature.tsx
+++ b/packages/ui/primitives/document-flow/add-signature.tsx
@@ -44,6 +44,7 @@ export type AddSignatureFormProps = {
onSubmit: (_data: TAddSignatureFormSchema) => Promise
| void;
requireName?: boolean;
+ requireCustomText?: boolean;
requireSignature?: boolean;
};
@@ -54,6 +55,7 @@ export const AddSignatureFormPartial = ({
onSubmit,
requireName = false,
+ requireCustomText = false,
requireSignature = true,
}: AddSignatureFormProps) => {
const { currentStep, totalSteps } = useStep();
@@ -70,6 +72,14 @@ export const AddSignatureFormPartial = ({
});
}
+ if (requireCustomText && val.customText.length === 0) {
+ ctx.addIssue({
+ path: ['customText'],
+ code: 'custom',
+ message: 'Text is required',
+ });
+ }
+
if (requireSignature && val.signature.length === 0) {
ctx.addIssue({
path: ['signature'],
@@ -85,6 +95,7 @@ export const AddSignatureFormPartial = ({
name: '',
email: '',
signature: '',
+ customText: '',
},
});
@@ -131,6 +142,11 @@ export const AddSignatureFormPartial = ({
return !form.formState.errors.email;
}
+ if (fieldType === FieldType.TEXT) {
+ await form.trigger('customText');
+ return !form.formState.errors.customText;
+ }
+
return true;
};
@@ -154,6 +170,11 @@ export const AddSignatureFormPartial = ({
customText: form.getValues('name'),
inserted: true,
}))
+ .with(FieldType.TEXT, () => ({
+ ...field,
+ customText: form.getValues('customText'),
+ inserted: true,
+ }))
.with(FieldType.SIGNATURE, () => {
const value = form.getValues('signature');
@@ -302,6 +323,29 @@ export const AddSignatureFormPartial = ({
)}
/>
)}
+
+ {requireCustomText && (
+ (
+
+ Custom Text
+
+ {
+ onFormValueChange(FieldType.TEXT);
+ field.onChange(value);
+ }}
+ />
+
+
+
+ )}
+ />
+ )}
@@ -330,7 +374,7 @@ export const AddSignatureFormPartial = ({
{localFields.map((field) =>
match(field.type)
- .with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, () => {
+ .with(FieldType.DATE, FieldType.TEXT, FieldType.EMAIL, FieldType.NAME, () => {
return (
= {
- SIGNER: ,
- APPROVER: ,
- CC: ,
- VIEWER: ,
-};
-
export type AddSignersFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx
index 40e42e3b3..bfc7f3fc5 100644
--- a/packages/ui/primitives/document-flow/add-subject.tsx
+++ b/packages/ui/primitives/document-flow/add-subject.tsx
@@ -226,7 +226,7 @@ export const AddSubjectFormPartial = ({
>
)}
-
+
diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
index ebe48b562..87ec48ad1 100644
--- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
+++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
@@ -5,10 +5,10 @@ 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 { Controller, useFieldArray, useForm } from 'react-hook-form';
import { nanoid } from '@documenso/lib/universal/id';
-import type { Field, Recipient } from '@documenso/prisma/client';
+import { type Field, type Recipient, RecipientRole } 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';
@@ -21,6 +21,8 @@ import {
DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root';
import type { DocumentFlowStep } from '../document-flow/types';
+import { ROLE_ICONS } from '../recipient-role-icons';
+import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
import { useStep } from '../stepper';
import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
@@ -59,12 +61,14 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
+ role: recipient.role,
}))
: [
{
formId: initialId,
name: `Recipient 1`,
email: `recipient.1@documenso.com`,
+ role: RecipientRole.SIGNER,
},
],
},
@@ -86,6 +90,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
formId: nanoid(12),
name: `Recipient ${placeholderRecipientCount}`,
email: `recipient.${placeholderRecipientCount}@documenso.com`,
+ role: RecipientRole.SIGNER,
});
setPlaceholderRecipientCount((count) => count + 1);
@@ -95,12 +100,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
removeSigner(index);
};
- const onKeyDown = (event: React.KeyboardEvent
) => {
- if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
- onAddPlaceholderRecipient();
- }
- };
-
return (
<>
@@ -113,10 +112,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
className="flex flex-wrap items-end gap-x-4"
>
-
+
+
+
(
+
+ )}
+ />
+
+