feat: add inbox

This commit is contained in:
David Nguyen
2023-08-07 23:10:27 +10:00
committed by Mythie
parent 04f6df6839
commit 8fd9730e2b
22 changed files with 966 additions and 177 deletions

View File

@ -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"

View File

@ -0,0 +1,14 @@
import Inbox from '~/components/(dashboard)/inbox/inbox';
export default function InboxPage() {
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">Inbox</h1>
<h3>Documents which you have been requested to sign.</h3>
<div className="mt-8">
<Inbox className="4xl:h-[70vh] sm:h-[40rem]" />
</div>
</div>
);
}

View File

@ -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 = (
<span
className={cn(
'ml-auto inline-flex items-center gap-x-1.5 rounded-md px-2 py-1 text-xs font-medium',
{
'bg-green-100 text-green-700': document.recipient.signingStatus === 'SIGNED',
'bg-yellow-100 text-yellow-800': document.recipient.signingStatus === 'NOT_SIGNED',
},
)}
>
<div
className={cn([
'h-1.5 w-1.5 rounded-full',
{
'bg-green-500': document.recipient.signingStatus === 'SIGNED',
'bg-yellow-500': document.recipient.signingStatus === 'NOT_SIGNED',
},
])}
></div>
{document.recipient.signingStatus === 'SIGNED' ? 'Signed' : 'Pending'}
</span>
);
return (
<div className="h-full">
<div className="hidden h-14 w-full flex-row items-center border-b px-4 sm:flex">
<div>
<h2 className="text-sm font-semibold">{document.subject}</h2>
<p className="text-xs">
{document.sender.name} <span className="">&lt;{document.sender.email}&gt;</span>
</p>
</div>
<div className="ml-auto flex flex-row items-center">
{/* Todo: This needs to be updated to when the document was sent to the recipient when that value is available. */}
<p className="mx-2 text-xs">{formatInboxDate(document.created)}</p>
{inboxDocumentStatusIndicator}
</div>
</div>
<div className="flex flex-row justify-end px-4 pt-4 sm:hidden">
{inboxDocumentStatusIndicator}
</div>
{/* Todo: get correct URLs */}
<div className="mx-auto mb-6 mt-0 w-full max-w-xl sm:mb-16 sm:mt-14 sm:p-4">
{document.recipient.signingStatus === 'NOT_SIGNED' && (
<TemplateDocumentInvite
inviterName={document.sender.name ?? document.sender.email}
inviterEmail={document.sender.email}
documentName={document.title}
signDocumentLink={'Todo'}
assetBaseUrl={location.origin}
/>
)}
{document.recipient.signingStatus === 'SIGNED' && (
<TemplateDocumentCompleted
downloadLink={'Todo'}
reviewLink={'Todo'}
documentName={document.title}
assetBaseUrl={location.origin}
/>
)}
</div>
</div>
);
}

View File

@ -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',
},
});
}

View File

@ -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 (
<section
className={cn('flex flex-col items-center justify-center rounded-lg border', className)}
>
<p className="text-neutral-500">Something went wrong while loading your inbox.</p>
<button onClick={() => refetch()} className="text-sm text-blue-500 hover:text-blue-600">
Click here to try again
</button>
</section>
);
}
return (
<section className={cn('flex flex-col rounded-lg border sm:flex-row sm:divide-x', className)}>
<div className="w-full sm:w-1/3">
{/* Header with search and filter options. */}
<div className="flex h-14 flex-row items-center justify-between border-b px-2 py-2">
<Input
placeholder="Search"
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="ml-2">
<Select
defaultValue={parsedSearchParams.filter}
onValueChange={(value) =>
updateSearchParams({
filter: value || null,
})
}
>
<SelectTrigger className="max-w-[200px] text-slate-500">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="">All</SelectItem>
<SelectItem value={SigningStatus['NOT_SIGNED']}>Pending</SelectItem>
<SelectItem value={SigningStatus['SIGNED']}>Approved</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="h-[calc(100%-3.5rem)] overflow-y-scroll">
{/* Handle rendering no items found. */}
{!isFetching && inboxDocuments.length === 0 && (
<div className="flex h-full items-center justify-center">
<p className="-mt-32 text-center text-sm text-neutral-500">No documents found.</p>
</div>
)}
{hasPreviousPage && !isFetchingPreviousPage && (
<button
onClick={() => fetchPreviousPage()}
className="mx-auto w-full border-b py-2 text-center text-sm text-slate-400"
>
Show previous
</button>
)}
<ul>
{/* Handle rendering skeleton on first load. */}
{isFetching &&
isInitialLoad &&
!data &&
Array.from({ length: numberOfSkeletons }).map((_, i) => (
<li
key={`skeleton-${i}`}
className="hover:bg-muted/50 flex w-full cursor-pointer flex-row items-center border-b py-3 pr-4 text-left transition-colors"
>
<Skeleton className="mx-3 h-2 w-2 rounded-full" />
<div className="w-full">
<div className="flex flex-row items-center justify-between">
<Skeleton className="h-5 w-6/12" />
<Skeleton className="h-4 w-2/12" />
</div>
<Skeleton className="my-1 h-4 w-8/12" />
<Skeleton className="h-4 w-4/12" />
</div>
</li>
))}
{/* Handle rendering list of inbox documents. */}
{inboxDocuments.map((item, i) => (
<li key={i}>
<button
onClick={() => 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,
},
)}
>
<div
className={cn([
'mx-3 h-2 w-2 rounded-full',
{
'bg-green-300': item.recipient.signingStatus === 'SIGNED',
'bg-yellow-300': item.recipient.signingStatus === 'NOT_SIGNED',
},
])}
></div>
<div className="w-full">
<div className="flex flex-row items-center justify-between">
<p className="line-clamp-1 text-sm">{item.subject}</p>
{/* Todo: This needs to be updated to when the document was sent to the recipient when that value is available. */}
<p className="whitespace-nowrap text-xs">{formatInboxDate(item.created)}</p>
</div>
{item.description && (
<p
className={cn('my-1 text-xs text-slate-500', {
'line-clamp-1': selectedDocument?.id !== item.id,
})}
>
{item.description}
</p>
)}
<p className="text-xs text-slate-400">
{item.sender.name} <span className="">&lt;{item.sender.email}&gt;</span>
</p>
</div>
</button>
{/* Mobile inbox content. */}
{selectedDocument?.id === item.id && (
<div
className={cn('w-full sm:hidden', {
'border-b': i !== inboxDocuments.length - 1,
})}
>
<InboxContent document={selectedDocument} />
</div>
)}
</li>
))}
</ul>
{hasNextPage && !isFetchingNextPage && (
<button
onClick={() => fetchNextPage()}
className="mx-auto w-full py-2 text-center text-sm text-slate-400"
>
Show more
</button>
)}
</div>
</div>
{/* Desktop inbox content. */}
<div className="hidden sm:block sm:w-2/3">
{selectedDocument ? (
<InboxContent document={selectedDocument} />
) : (
<div className="hidden h-full items-center justify-center text-slate-300 sm:flex">
<InboxIcon className="h-12 w-12" />
</div>
)}
</div>
</section>
);
}

View File

@ -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',
});
};

View File

@ -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<HTMLDivElement>;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const pathname = usePathname();
return (
<div className={cn('ml-8 hidden flex-1 gap-x-6 md:flex', className)} {...props}>
{/* No Nav tabs while there is only one main page */}
{/* <Link
<Link
href="/documents"
className={cn(
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 ',
@ -20,7 +24,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
)}
>
Documents
</Link> */}
</Link>
<Link
href="/inbox"
className={cn(
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 ',
{
'text-foreground': pathname?.startsWith('/inbox'),
},
)}
>
Inbox
</Link>
</div>
);
};

View File

@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
export function useDebouncedValue<T>(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;
}

View File

@ -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}`,
],
};