diff --git a/apps/web/package.json b/apps/web/package.json
index c0a3035ea..22a75128f 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -21,6 +21,7 @@
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
"lucide-react": "^0.214.0",
+ "luxon": "^3.4.0",
"micro": "^10.0.1",
"nanoid": "^4.0.2",
"next": "13.4.12",
@@ -43,6 +44,7 @@
},
"devDependencies": {
"@types/formidable": "^2.0.6",
+ "@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7"
diff --git a/apps/web/src/app/(dashboard)/inbox/page.tsx b/apps/web/src/app/(dashboard)/inbox/page.tsx
new file mode 100644
index 000000000..badb421c9
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/inbox/page.tsx
@@ -0,0 +1,14 @@
+import Inbox from '~/components/(dashboard)/inbox/inbox';
+
+export default function InboxPage() {
+ return (
+
+
Inbox
+
Documents which you have been requested to sign.
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/(dashboard)/inbox/inbox-content.tsx b/apps/web/src/components/(dashboard)/inbox/inbox-content.tsx
new file mode 100644
index 000000000..f7e263f1f
--- /dev/null
+++ b/apps/web/src/components/(dashboard)/inbox/inbox-content.tsx
@@ -0,0 +1,82 @@
+import { TemplateDocumentCompleted } from '@documenso/email/template-components/template-document-completed';
+import { TemplateDocumentInvite } from '@documenso/email/template-components/template-document-invite';
+import { DocumentWithRecipientAndSender } from '@documenso/prisma/types/document';
+import { cn } from '@documenso/ui/lib/utils';
+
+import { formatInboxDate } from './inbox.utils';
+
+export type InboxContentProps = {
+ document: DocumentWithRecipientAndSender;
+};
+
+export default function InboxContent({ document }: InboxContentProps) {
+ const inboxDocumentStatusIndicator = (
+
+
+
+ {document.recipient.signingStatus === 'SIGNED' ? 'Signed' : 'Pending'}
+
+ );
+
+ return (
+
+
+
+
{document.subject}
+
+ {document.sender.name} <{document.sender.email}>
+
+
+
+
+ {/* Todo: This needs to be updated to when the document was sent to the recipient when that value is available. */}
+
{formatInboxDate(document.created)}
+
+ {inboxDocumentStatusIndicator}
+
+
+
+
+ {inboxDocumentStatusIndicator}
+
+
+ {/* Todo: get correct URLs */}
+
+ {document.recipient.signingStatus === 'NOT_SIGNED' && (
+
+ )}
+
+ {document.recipient.signingStatus === 'SIGNED' && (
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/(dashboard)/inbox/inbox.actions.ts b/apps/web/src/components/(dashboard)/inbox/inbox.actions.ts
new file mode 100644
index 000000000..38b50a8b3
--- /dev/null
+++ b/apps/web/src/components/(dashboard)/inbox/inbox.actions.ts
@@ -0,0 +1,24 @@
+'use server';
+
+import { z } from 'zod';
+
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
+import { prisma } from '@documenso/prisma';
+
+export async function updateRecipientReadStatus(recipientId: number, documentId: number) {
+ z.number().parse(recipientId);
+ z.number().parse(documentId);
+
+ const { email } = await getRequiredServerComponentSession();
+
+ await prisma.recipient.update({
+ where: {
+ id: recipientId,
+ documentId,
+ email,
+ },
+ data: {
+ readStatus: 'OPENED',
+ },
+ });
+}
diff --git a/apps/web/src/components/(dashboard)/inbox/inbox.tsx b/apps/web/src/components/(dashboard)/inbox/inbox.tsx
new file mode 100644
index 000000000..c76ba3d94
--- /dev/null
+++ b/apps/web/src/components/(dashboard)/inbox/inbox.tsx
@@ -0,0 +1,352 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+
+import { Inbox as InboxIcon } from 'lucide-react';
+import { z } from 'zod';
+
+import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
+import { SigningStatus } from '@documenso/prisma/client';
+import { DocumentWithRecipientAndSender } from '@documenso/prisma/types/document';
+import { trpc } from '@documenso/trpc/react';
+import { cn } from '@documenso/ui/lib/utils';
+import { Input } from '@documenso/ui/primitives/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@documenso/ui/primitives/select';
+import { Skeleton } from '@documenso/ui/primitives/skeleton';
+
+import { useDebouncedValue } from '~/hooks/use-debounced-value';
+
+import InboxContent from './inbox-content';
+import { updateRecipientReadStatus } from './inbox.actions';
+import { formatInboxDate } from './inbox.utils';
+
+export const ZInboxSearchParamsSchema = z.object({
+ filter: z
+ .union([z.literal('SIGNED'), z.literal('NOT_SIGNED'), z.undefined()])
+ .catch(() => undefined),
+ id: z
+ .string()
+ .optional()
+ .catch(() => undefined),
+ query: z
+ .string()
+ .optional()
+ .catch(() => undefined),
+});
+
+export type InboxProps = {
+ className?: string;
+};
+
+const numberOfSkeletons = 3;
+
+export default function Inbox(props: InboxProps) {
+ const { className } = props;
+
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const parsedSearchParams = ZInboxSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
+
+ const [searchQuery, setSearchQuery] = useState(() => parsedSearchParams.query || '');
+
+ const [readStatusState, setReadStatusState] = useState<{
+ [documentId: string]: 'ERROR' | 'UPDATED' | 'UPDATING';
+ }>({});
+
+ const [isInitialLoad, setIsInitialLoad] = useState(true);
+
+ const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
+
+ const {
+ data,
+ error,
+ fetchNextPage,
+ fetchPreviousPage,
+ hasNextPage,
+ hasPreviousPage,
+ isFetching,
+ isFetchingNextPage,
+ isFetchingPreviousPage,
+ refetch,
+ } = trpc.document.searchInboxDocuments.useInfiniteQuery(
+ {
+ query: parsedSearchParams.query,
+ filter: parsedSearchParams.filter,
+ },
+ {
+ getPreviousPageParam: (firstPage) =>
+ firstPage.currentPage > 1 ? firstPage.currentPage - 1 : undefined,
+ getNextPageParam: (lastPage) =>
+ lastPage.currentPage < lastPage.totalPages ? lastPage.currentPage + 1 : undefined,
+ },
+ );
+
+ /**
+ * The current documents in the inbox after filters and queries have been applied.
+ */
+ const inboxDocuments = (data?.pages ?? []).flatMap((page) => page.data);
+
+ /**
+ * The currently selected document in the inbox.
+ */
+ const selectedDocument: DocumentWithRecipientAndSender | null =
+ inboxDocuments.find((item) => item.id.toString() === parsedSearchParams.id) ?? null;
+
+ /**
+ * Remove the ID from the query if it is not found in the result.
+ */
+ useEffect(() => {
+ if (!selectedDocument && parsedSearchParams.id && data) {
+ updateSearchParams({
+ id: null,
+ });
+ }
+ }, [data, selectedDocument, parsedSearchParams.id]);
+
+ /**
+ * Handle debouncing the seach query.
+ */
+ useEffect(() => {
+ if (!pathname) {
+ return;
+ }
+
+ const params = new URLSearchParams(searchParams?.toString());
+
+ params.set('query', debouncedSearchQuery);
+
+ if (debouncedSearchQuery === '') {
+ params.delete('query');
+ }
+
+ router.push(`${pathname}?${params.toString()}`);
+ }, [debouncedSearchQuery]);
+
+ useEffect(() => {
+ if (!isFetching) {
+ setIsInitialLoad(false);
+ }
+ }, [isFetching]);
+
+ const updateReadStatusState = (documentId: number, value: (typeof readStatusState)[string]) => {
+ setReadStatusState({
+ ...readStatusState,
+ [documentId]: value,
+ });
+ };
+
+ /**
+ * Handle selecting the selected document to display and updating the read status if required.
+ */
+ const onSelectedDocumentChange = (value: DocumentWithRecipientAndSender) => {
+ if (!pathname) {
+ return;
+ }
+
+ // Update the read status.
+ if (
+ value.recipient.readStatus === 'NOT_OPENED' &&
+ readStatusState[value.id] !== 'UPDATED' &&
+ readStatusState[value.id] !== 'UPDATING'
+ ) {
+ updateReadStatusState(value.id, 'UPDATING');
+
+ updateRecipientReadStatus(value.recipient.id, value.id)
+ .then(() => {
+ updateReadStatusState(value.id, 'UPDATED');
+ })
+ .catch(() => {
+ updateReadStatusState(value.id, 'ERROR');
+ });
+ }
+
+ const params = new URLSearchParams(searchParams?.toString());
+
+ params.set('id', value.id.toString());
+
+ router.push(`${pathname}?${params.toString()}`);
+ };
+
+ if (error) {
+ return (
+
+ Something went wrong while loading your inbox.
+ refetch()} className="text-sm text-blue-500 hover:text-blue-600">
+ Click here to try again
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header with search and filter options. */}
+
+
setSearchQuery(e.target.value)}
+ />
+
+
+
+ updateSearchParams({
+ filter: value || null,
+ })
+ }
+ >
+
+
+
+
+
+ All
+ Pending
+ Approved
+
+
+
+
+
+
+ {/* Handle rendering no items found. */}
+ {!isFetching && inboxDocuments.length === 0 && (
+
+ )}
+
+ {hasPreviousPage && !isFetchingPreviousPage && (
+
fetchPreviousPage()}
+ className="mx-auto w-full border-b py-2 text-center text-sm text-slate-400"
+ >
+ Show previous
+
+ )}
+
+
+ {/* Handle rendering skeleton on first load. */}
+ {isFetching &&
+ isInitialLoad &&
+ !data &&
+ Array.from({ length: numberOfSkeletons }).map((_, i) => (
+
+
+
+
+
+ ))}
+
+ {/* Handle rendering list of inbox documents. */}
+ {inboxDocuments.map((item, i) => (
+
+ onSelectedDocumentChange(item)}
+ className={cn(
+ 'hover:bg-muted/50 flex w-full cursor-pointer flex-row items-center border-b py-3 pr-4 text-left transition-colors',
+ {
+ 'bg-muted/50 dark:bg-muted': selectedDocument?.id === item.id,
+ },
+ )}
+ >
+
+
+
+
+
{item.subject}
+
+ {/* Todo: This needs to be updated to when the document was sent to the recipient when that value is available. */}
+
{formatInboxDate(item.created)}
+
+
+ {item.description && (
+
+ {item.description}
+
+ )}
+
+
+ {item.sender.name} <{item.sender.email}>
+
+
+
+
+ {/* Mobile inbox content. */}
+ {selectedDocument?.id === item.id && (
+
+
+
+ )}
+
+ ))}
+
+
+ {hasNextPage && !isFetchingNextPage && (
+
fetchNextPage()}
+ className="mx-auto w-full py-2 text-center text-sm text-slate-400"
+ >
+ Show more
+
+ )}
+
+
+
+ {/* Desktop inbox content. */}
+
+ {selectedDocument ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/(dashboard)/inbox/inbox.utils.ts b/apps/web/src/components/(dashboard)/inbox/inbox.utils.ts
new file mode 100644
index 000000000..9a6aff9f3
--- /dev/null
+++ b/apps/web/src/components/(dashboard)/inbox/inbox.utils.ts
@@ -0,0 +1,23 @@
+import { DateTime } from 'luxon';
+
+/**
+ * Format the provided date into a readable string for inboxes.
+ *
+ * @param dateValue The date or date string
+ * @returns The date in the current locale, or the date formatted as HH:MM AM/PM if the provided date is after 12:00AM of the current date
+ */
+export const formatInboxDate = (dateValue: string | Date): string => {
+ const date =
+ typeof dateValue === 'string' ? DateTime.fromISO(dateValue) : DateTime.fromJSDate(dateValue);
+
+ const startOfTheDay = DateTime.now().startOf('day');
+
+ if (date >= startOfTheDay) {
+ return date.toFormat('h:mma');
+ }
+
+ return date.toLocaleString({
+ ...DateTime.DATE_SHORT,
+ year: '2-digit',
+ });
+};
diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx
index f07d2baf4..6d0e629b4 100644
--- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx
+++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx
@@ -2,15 +2,19 @@
import { HTMLAttributes } from 'react';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
import { cn } from '@documenso/ui/lib/utils';
export type DesktopNavProps = HTMLAttributes;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
+ const pathname = usePathname();
+
return (
- {/* No Nav tabs while there is only one main page */}
- {/* {
)}
>
Documents
- */}
+
+
+
+ Inbox
+
);
};
diff --git a/apps/web/src/hooks/use-debounced-value.ts b/apps/web/src/hooks/use-debounced-value.ts
new file mode 100644
index 000000000..3c57f4aa0
--- /dev/null
+++ b/apps/web/src/hooks/use-debounced-value.ts
@@ -0,0 +1,18 @@
+import { useEffect, useState } from 'react';
+
+export function useDebouncedValue(value: T, delay: number) {
+ // State and setters for debounced value
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js
index c7f6f1a19..fcc9f7032 100644
--- a/apps/web/tailwind.config.js
+++ b/apps/web/tailwind.config.js
@@ -7,5 +7,6 @@ module.exports = {
content: [
...baseConfig.content,
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
+ `${path.join(require.resolve('@documenso/email'), '..')}/**/*.{ts,tsx}`,
],
};
diff --git a/package-lock.json b/package-lock.json
index 736a9e0e1..0b6f72f42 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -79,6 +79,7 @@
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
"lucide-react": "^0.214.0",
+ "luxon": "^3.4.0",
"micro": "^10.0.1",
"nanoid": "^4.0.2",
"next": "13.4.12",
@@ -101,6 +102,7 @@
},
"devDependencies": {
"@types/formidable": "^2.0.6",
+ "@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7"
diff --git a/packages/email/template-components/template-document-completed.tsx b/packages/email/template-components/template-document-completed.tsx
new file mode 100644
index 000000000..b64b13cff
--- /dev/null
+++ b/packages/email/template-components/template-document-completed.tsx
@@ -0,0 +1,71 @@
+import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
+
+import * as config from '@documenso/tailwind-config';
+
+export interface TemplateDocumentCompletedProps {
+ downloadLink: string;
+ reviewLink: string;
+ documentName: string;
+ assetBaseUrl: string;
+}
+
+export const TemplateDocumentCompleted = ({
+ downloadLink,
+ reviewLink,
+ documentName,
+ assetBaseUrl,
+}: TemplateDocumentCompletedProps) => {
+ const getAssetUrl = (path: string) => {
+ return new URL(path, assetBaseUrl).toString();
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default TemplateDocumentCompleted;
diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx
new file mode 100644
index 000000000..bf2fb905e
--- /dev/null
+++ b/packages/email/template-components/template-document-invite.tsx
@@ -0,0 +1,59 @@
+import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
+
+import * as config from '@documenso/tailwind-config';
+
+export interface TemplateDocumentInviteProps {
+ inviterName: string;
+ inviterEmail: string;
+ documentName: string;
+ signDocumentLink: string;
+ assetBaseUrl: string;
+}
+
+export const TemplateDocumentInvite = ({
+ inviterName,
+ documentName,
+ signDocumentLink,
+ assetBaseUrl,
+}: TemplateDocumentInviteProps) => {
+ const getAssetUrl = (path: string) => {
+ return new URL(path, assetBaseUrl).toString();
+ };
+
+ return (
+
+
+
+
+
+
+
+ {inviterName} has invited you to sign "{documentName}"
+
+
+
+ Continue by signing the document.
+
+
+
+
+
+ );
+};
+
+export default TemplateDocumentInvite;
diff --git a/packages/email/template-components/template-document-pending.tsx b/packages/email/template-components/template-document-pending.tsx
new file mode 100644
index 000000000..80387b783
--- /dev/null
+++ b/packages/email/template-components/template-document-pending.tsx
@@ -0,0 +1,52 @@
+import { Img, Section, Tailwind, Text } from '@react-email/components';
+
+import * as config from '@documenso/tailwind-config';
+
+export interface TemplateDocumentPendingProps {
+ documentName: string;
+ assetBaseUrl: string;
+}
+
+export const TemplateDocumentPending = ({
+ documentName,
+ assetBaseUrl,
+}: TemplateDocumentPendingProps) => {
+ const getAssetUrl = (path: string) => {
+ return new URL(path, assetBaseUrl).toString();
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Waiting for others
+
+
+
+ “{documentName}” has been signed
+
+
+
+ We're still waiting for other signers to sign this document.
+
+ We'll notify you as soon as it's ready.
+
+
+
+ );
+};
+
+export default TemplateDocumentPending;
diff --git a/packages/email/template-components/template-footer.tsx b/packages/email/template-components/template-footer.tsx
new file mode 100644
index 000000000..ee395a1e9
--- /dev/null
+++ b/packages/email/template-components/template-footer.tsx
@@ -0,0 +1,22 @@
+import { Link, Section, Text } from '@react-email/components';
+
+export const TemplateFooter = () => {
+ return (
+
+
+ This document was sent using{' '}
+
+ Documenso.
+
+
+
+
+ Documenso
+
+ 2261 Market Street, #5211, San Francisco, CA 94114, USA
+
+
+ );
+};
+
+export default TemplateFooter;
diff --git a/packages/email/templates/document-completed.tsx b/packages/email/templates/document-completed.tsx
index 0d4f70601..9152d5822 100644
--- a/packages/email/templates/document-completed.tsx
+++ b/packages/email/templates/document-completed.tsx
@@ -1,25 +1,23 @@
import {
Body,
- Button,
Container,
Head,
Html,
Img,
- Link,
Preview,
Section,
Tailwind,
- Text,
} from '@react-email/components';
import config from '@documenso/tailwind-config';
-interface DocumentCompletedEmailTemplateProps {
- downloadLink?: string;
- reviewLink?: string;
- documentName?: string;
- assetBaseUrl?: string;
-}
+import {
+ TemplateDocumentCompleted,
+ TemplateDocumentCompletedProps,
+} from '../template-components/template-document-completed';
+import TemplateFooter from '../template-components/template-footer';
+
+export type DocumentCompletedEmailTemplateProps = Partial;
export const DocumentCompletedEmailTemplate = ({
downloadLink = 'https://documenso.com',
@@ -50,74 +48,23 @@ export const DocumentCompletedEmailTemplate = ({
-
+
-
+
-
-
- This document was sent using{' '}
-
- Documenso.
-
-
-
-
- Documenso
-
- 2261 Market Street, #5211, San Francisco, CA 94114, USA
-
-
+