mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: add inbox
This commit is contained in:
@ -21,6 +21,7 @@
|
|||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.214.0",
|
"lucide-react": "^0.214.0",
|
||||||
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
@ -43,6 +44,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7"
|
||||||
|
|||||||
14
apps/web/src/app/(dashboard)/inbox/page.tsx
Normal file
14
apps/web/src/app/(dashboard)/inbox/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
apps/web/src/components/(dashboard)/inbox/inbox-content.tsx
Normal file
82
apps/web/src/components/(dashboard)/inbox/inbox-content.tsx
Normal 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=""><{document.sender.email}></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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/web/src/components/(dashboard)/inbox/inbox.actions.ts
Normal file
24
apps/web/src/components/(dashboard)/inbox/inbox.actions.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
352
apps/web/src/components/(dashboard)/inbox/inbox.tsx
Normal file
352
apps/web/src/components/(dashboard)/inbox/inbox.tsx
Normal 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=""><{item.sender.email}></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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/web/src/components/(dashboard)/inbox/inbox.utils.ts
Normal file
23
apps/web/src/components/(dashboard)/inbox/inbox.utils.ts
Normal 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',
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -2,15 +2,19 @@
|
|||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('ml-8 hidden flex-1 gap-x-6 md:flex', className)} {...props}>
|
<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"
|
href="/documents"
|
||||||
className={cn(
|
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-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
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
18
apps/web/src/hooks/use-debounced-value.ts
Normal file
18
apps/web/src/hooks/use-debounced-value.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -7,5 +7,6 @@ module.exports = {
|
|||||||
content: [
|
content: [
|
||||||
...baseConfig.content,
|
...baseConfig.content,
|
||||||
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
|
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
|
||||||
|
`${path.join(require.resolve('@documenso/email'), '..')}/**/*.{ts,tsx}`,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@ -79,6 +79,7 @@
|
|||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.214.0",
|
"lucide-react": "^0.214.0",
|
||||||
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
@ -101,6 +102,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7"
|
||||||
|
|||||||
@ -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 (
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: config.theme.extend.colors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Section className="flex-row items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
|
||||||
|
<Img src={getAssetUrl('/static/completed.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
||||||
|
Completed
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-primary mb-0 text-center text-lg font-semibold">
|
||||||
|
“{documentName}” was signed by all signers
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
|
Continue by downloading or reviewing the document.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section className="mb-6 mt-8 text-center">
|
||||||
|
<Button
|
||||||
|
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||||
|
href={reviewLink}
|
||||||
|
>
|
||||||
|
<Img src={getAssetUrl('/static/review.png')} className="-mb-1 mr-2 inline h-5 w-5" />
|
||||||
|
Review
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||||
|
href={downloadLink}
|
||||||
|
>
|
||||||
|
<Img src={getAssetUrl('/static/download.png')} className="-mb-1 mr-2 inline h-5 w-5" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
</Tailwind>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateDocumentCompleted;
|
||||||
@ -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 (
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: config.theme.extend.colors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Section className="mt-4 flex-row items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||||
|
{inviterName} has invited you to sign "{documentName}"
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
|
Continue by signing the document.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section className="mb-6 mt-8 text-center">
|
||||||
|
<Button
|
||||||
|
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||||
|
href={signDocumentLink}
|
||||||
|
>
|
||||||
|
Sign Document
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
</Tailwind>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateDocumentInvite;
|
||||||
@ -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 (
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: config.theme.extend.colors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Section className="flex-row items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-blue-500">
|
||||||
|
<Img src={getAssetUrl('/static/clock.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
||||||
|
Waiting for others
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-primary mb-0 text-center text-lg font-semibold">
|
||||||
|
“{documentName}” has been signed
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="mx-auto mb-6 mt-1 max-w-[80%] text-center text-base text-slate-400">
|
||||||
|
We're still waiting for other signers to sign this document.
|
||||||
|
<br />
|
||||||
|
We'll notify you as soon as it's ready.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Tailwind>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateDocumentPending;
|
||||||
22
packages/email/template-components/template-footer.tsx
Normal file
22
packages/email/template-components/template-footer.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Link, Section, Text } from '@react-email/components';
|
||||||
|
|
||||||
|
export const TemplateFooter = () => {
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<Text className="my-4 text-base text-slate-400">
|
||||||
|
This document was sent using{' '}
|
||||||
|
<Link className="text-[#7AC455]" href="https://documenso.com">
|
||||||
|
Documenso.
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="my-8 text-sm text-slate-400">
|
||||||
|
Documenso
|
||||||
|
<br />
|
||||||
|
2261 Market Street, #5211, San Francisco, CA 94114, USA
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateFooter;
|
||||||
@ -1,25 +1,23 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Button,
|
|
||||||
Container,
|
Container,
|
||||||
Head,
|
Head,
|
||||||
Html,
|
Html,
|
||||||
Img,
|
Img,
|
||||||
Link,
|
|
||||||
Preview,
|
Preview,
|
||||||
Section,
|
Section,
|
||||||
Tailwind,
|
Tailwind,
|
||||||
Text,
|
|
||||||
} from '@react-email/components';
|
} from '@react-email/components';
|
||||||
|
|
||||||
import config from '@documenso/tailwind-config';
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
interface DocumentCompletedEmailTemplateProps {
|
import {
|
||||||
downloadLink?: string;
|
TemplateDocumentCompleted,
|
||||||
reviewLink?: string;
|
TemplateDocumentCompletedProps,
|
||||||
documentName?: string;
|
} from '../template-components/template-document-completed';
|
||||||
assetBaseUrl?: string;
|
import TemplateFooter from '../template-components/template-footer';
|
||||||
}
|
|
||||||
|
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps>;
|
||||||
|
|
||||||
export const DocumentCompletedEmailTemplate = ({
|
export const DocumentCompletedEmailTemplate = ({
|
||||||
downloadLink = 'https://documenso.com',
|
downloadLink = 'https://documenso.com',
|
||||||
@ -50,74 +48,23 @@ export const DocumentCompletedEmailTemplate = ({
|
|||||||
<Section className="bg-white">
|
<Section className="bg-white">
|
||||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||||
<Section className="p-2">
|
<Section className="p-2">
|
||||||
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="h-6" />
|
<Img
|
||||||
|
src={getAssetUrl('/static/logo.png')}
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="mb-4 h-6"
|
||||||
|
/>
|
||||||
|
|
||||||
<Section className="mt-4 flex-row items-center justify-center">
|
<TemplateDocumentCompleted
|
||||||
<div className="flex items-center justify-center p-4">
|
downloadLink={downloadLink}
|
||||||
<Img
|
reviewLink={reviewLink}
|
||||||
className="h-42"
|
documentName={documentName}
|
||||||
src={getAssetUrl('/static/document.png')}
|
assetBaseUrl={assetBaseUrl}
|
||||||
alt="Documenso"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
|
|
||||||
<Img
|
|
||||||
src={getAssetUrl('/static/completed.png')}
|
|
||||||
className="-mb-0.5 mr-2 inline h-7 w-7"
|
|
||||||
/>
|
|
||||||
Completed
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className="text-primary mb-0 text-center text-lg font-semibold">
|
|
||||||
“{documentName}” was signed by all signers
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className="my-1 text-center text-base text-slate-400">
|
|
||||||
Continue by downloading or reviewing the document.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Section className="mb-6 mt-8 text-center">
|
|
||||||
<Button
|
|
||||||
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
|
||||||
href={reviewLink}
|
|
||||||
>
|
|
||||||
<Img
|
|
||||||
src={getAssetUrl('/static/review.png')}
|
|
||||||
className="-mb-1 mr-2 inline h-5 w-5"
|
|
||||||
/>
|
|
||||||
Review
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
|
||||||
href={downloadLink}
|
|
||||||
>
|
|
||||||
<Img
|
|
||||||
src={getAssetUrl('/static/download.png')}
|
|
||||||
className="-mb-1 mr-2 inline h-5 w-5"
|
|
||||||
/>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
</Section>
|
|
||||||
</Section>
|
|
||||||
</Section>
|
</Section>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<Container className="mx-auto max-w-xl">
|
<Container className="mx-auto max-w-xl">
|
||||||
<Section>
|
<TemplateFooter />
|
||||||
<Text className="my-4 text-base text-slate-400">
|
|
||||||
This document was sent using{' '}
|
|
||||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
|
||||||
Documenso.
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className="my-8 text-sm text-slate-400">
|
|
||||||
Documenso
|
|
||||||
<br />
|
|
||||||
2261 Market Street, #5211, San Francisco, CA 94114, USA
|
|
||||||
</Text>
|
|
||||||
</Section>
|
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
</Body>
|
</Body>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Button,
|
|
||||||
Container,
|
Container,
|
||||||
Head,
|
Head,
|
||||||
Hr,
|
Hr,
|
||||||
@ -15,13 +14,13 @@ import {
|
|||||||
|
|
||||||
import config from '@documenso/tailwind-config';
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
interface DocumentInviteEmailTemplateProps {
|
import {
|
||||||
inviterName?: string;
|
TemplateDocumentInvite,
|
||||||
inviterEmail?: string;
|
TemplateDocumentInviteProps,
|
||||||
documentName?: string;
|
} from '../template-components/template-document-invite';
|
||||||
signDocumentLink?: string;
|
import TemplateFooter from '../template-components/template-footer';
|
||||||
assetBaseUrl?: string;
|
|
||||||
}
|
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps>;
|
||||||
|
|
||||||
export const DocumentInviteEmailTemplate = ({
|
export const DocumentInviteEmailTemplate = ({
|
||||||
inviterName = 'Lucas Smith',
|
inviterName = 'Lucas Smith',
|
||||||
@ -51,36 +50,21 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
>
|
>
|
||||||
<Body className="mx-auto my-auto bg-white font-sans">
|
<Body className="mx-auto my-auto bg-white font-sans">
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||||
<Section className="p-2">
|
<Section>
|
||||||
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="h-6" />
|
<Img
|
||||||
|
src={getAssetUrl('/static/logo.png')}
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="mb-4 h-6"
|
||||||
|
/>
|
||||||
|
|
||||||
<Section className="mt-4 flex-row items-center justify-center">
|
<TemplateDocumentInvite
|
||||||
<div className="flex items-center justify-center p-4">
|
inviterName={inviterName}
|
||||||
<Img
|
inviterEmail={inviterEmail}
|
||||||
className="h-42"
|
documentName={documentName}
|
||||||
src={getAssetUrl('/static/document.png')}
|
signDocumentLink={signDocumentLink}
|
||||||
alt="Documenso"
|
assetBaseUrl={assetBaseUrl}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
|
||||||
{inviterName} has invited you to sign "{documentName}"
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className="my-1 text-center text-base text-slate-400">
|
|
||||||
Continue by signing the document.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Section className="mb-6 mt-8 text-center">
|
|
||||||
<Button
|
|
||||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
|
||||||
href={signDocumentLink}
|
|
||||||
>
|
|
||||||
Sign Document
|
|
||||||
</Button>
|
|
||||||
</Section>
|
|
||||||
</Section>
|
|
||||||
</Section>
|
</Section>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
@ -102,20 +86,7 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||||
|
|
||||||
<Container className="mx-auto max-w-xl">
|
<Container className="mx-auto max-w-xl">
|
||||||
<Section>
|
<TemplateFooter />
|
||||||
<Text className="my-4 text-base text-slate-400">
|
|
||||||
This document was sent using{' '}
|
|
||||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
|
||||||
Documenso.
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className="my-8 text-sm text-slate-400">
|
|
||||||
Documenso
|
|
||||||
<br />
|
|
||||||
2261 Market Street, #5211, San Francisco, CA 94114, USA
|
|
||||||
</Text>
|
|
||||||
</Section>
|
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
</Body>
|
</Body>
|
||||||
|
|||||||
@ -4,19 +4,20 @@ import {
|
|||||||
Head,
|
Head,
|
||||||
Html,
|
Html,
|
||||||
Img,
|
Img,
|
||||||
Link,
|
|
||||||
Preview,
|
Preview,
|
||||||
Section,
|
Section,
|
||||||
Tailwind,
|
Tailwind,
|
||||||
Text,
|
|
||||||
} from '@react-email/components';
|
} from '@react-email/components';
|
||||||
|
|
||||||
import config from '@documenso/tailwind-config';
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
interface DocumentPendingEmailTemplateProps {
|
import {
|
||||||
documentName?: string;
|
TemplateDocumentPending,
|
||||||
assetBaseUrl?: string;
|
TemplateDocumentPendingProps,
|
||||||
}
|
} from '../template-components/template-document-pending';
|
||||||
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
|
|
||||||
|
export type DocumentPendingEmailTemplateProps = Partial<TemplateDocumentPendingProps>;
|
||||||
|
|
||||||
export const DocumentPendingEmailTemplate = ({
|
export const DocumentPendingEmailTemplate = ({
|
||||||
documentName = 'Open Source Pledge.pdf',
|
documentName = 'Open Source Pledge.pdf',
|
||||||
@ -43,55 +44,20 @@ export const DocumentPendingEmailTemplate = ({
|
|||||||
>
|
>
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
<Section className="bg-white">
|
<Section className="bg-white">
|
||||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||||
<Section className="p-2">
|
<Section>
|
||||||
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="h-6" />
|
<Img
|
||||||
|
src={getAssetUrl('/static/logo.png')}
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="mb-4 h-6"
|
||||||
|
/>
|
||||||
|
|
||||||
<Section className="mt-4 flex-row items-center justify-center">
|
<TemplateDocumentPending documentName={documentName} assetBaseUrl={assetBaseUrl} />
|
||||||
<div className="flex items-center justify-center p-4">
|
|
||||||
<Img
|
|
||||||
className="h-42"
|
|
||||||
src={getAssetUrl('/static/document.png')}
|
|
||||||
alt="Documenso"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-blue-500">
|
|
||||||
<Img
|
|
||||||
src={getAssetUrl('/static/clock.png')}
|
|
||||||
className="-mb-0.5 mr-2 inline h-7 w-7"
|
|
||||||
/>
|
|
||||||
Waiting for others
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className="text-primary mb-0 text-center text-lg font-semibold">
|
|
||||||
“{documentName}” has been signed
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className="mx-auto mb-6 mt-1 max-w-[80%] text-center text-base text-slate-400">
|
|
||||||
We're still waiting for other signers to sign this document.
|
|
||||||
<br />
|
|
||||||
We'll notify you as soon as it's ready.
|
|
||||||
</Text>
|
|
||||||
</Section>
|
|
||||||
</Section>
|
</Section>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<Container className="mx-auto max-w-xl">
|
<Container className="mx-auto max-w-xl">
|
||||||
<Section>
|
<TemplateFooter />
|
||||||
<Text className="my-4 text-base text-slate-400">
|
|
||||||
This document was sent using{' '}
|
|
||||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
|
||||||
Documenso.
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className="my-8 text-sm text-slate-400">
|
|
||||||
Documenso
|
|
||||||
<br />
|
|
||||||
2261 Market Street, #5211, San Francisco, CA 94114, USA
|
|
||||||
</Text>
|
|
||||||
</Section>
|
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
</Body>
|
</Body>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { Document, DocumentStatus, Prisma } from '@documenso/prisma/client';
|
import { Document, DocumentStatus, Prisma, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
import { DocumentWithRecipientAndSender } from '@documenso/prisma/types/document';
|
||||||
import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient';
|
import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient';
|
||||||
|
|
||||||
import { FindResultSet } from '../../types/find-result-set';
|
import { FindResultSet } from '../../types/find-result-set';
|
||||||
@ -68,3 +69,111 @@ export const findDocuments = async ({
|
|||||||
totalPages: Math.ceil(count / perPage),
|
totalPages: Math.ceil(count / perPage),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface FindDocumentsWithRecipientAndSenderOptions {
|
||||||
|
email: string;
|
||||||
|
query?: string;
|
||||||
|
signingStatus?: SigningStatus;
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
orderBy?: {
|
||||||
|
column: keyof Omit<Document, 'document'>;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findDocumentsWithRecipientAndSender = async ({
|
||||||
|
email,
|
||||||
|
query,
|
||||||
|
signingStatus,
|
||||||
|
page = 1,
|
||||||
|
perPage = 20,
|
||||||
|
orderBy,
|
||||||
|
}: FindDocumentsWithRecipientAndSenderOptions): Promise<
|
||||||
|
FindResultSet<DocumentWithRecipientAndSender>
|
||||||
|
> => {
|
||||||
|
const orderByColumn = orderBy?.column ?? 'created';
|
||||||
|
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||||
|
|
||||||
|
const filters: Prisma.DocumentWhereInput = {
|
||||||
|
Recipient: {
|
||||||
|
some: {
|
||||||
|
email,
|
||||||
|
signingStatus,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
filters.OR = [
|
||||||
|
{
|
||||||
|
User: {
|
||||||
|
email: {
|
||||||
|
contains: query,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Todo: Add filter for `Subject`.
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, count] = await Promise.all([
|
||||||
|
prisma.document.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
created: true,
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
userId: true,
|
||||||
|
User: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Recipient: {
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
signingStatus,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
...filters,
|
||||||
|
},
|
||||||
|
skip: Math.max(page - 1, 0) * perPage,
|
||||||
|
take: perPage,
|
||||||
|
orderBy: {
|
||||||
|
[orderByColumn]: orderByDirection,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.document.count({
|
||||||
|
where: {
|
||||||
|
...filters,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data.map((item) => {
|
||||||
|
const { User, Recipient, ...rest } = item;
|
||||||
|
|
||||||
|
const subject = undefined; // Todo.
|
||||||
|
const description = undefined; // Todo.
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
sender: User,
|
||||||
|
recipient: Recipient[0],
|
||||||
|
subject: subject ?? 'Please sign this document',
|
||||||
|
description: description ?? `${User.name} has invited you to sign "${item.title}"`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
count,
|
||||||
|
currentPage: Math.max(page, 1),
|
||||||
|
perPage,
|
||||||
|
totalPages: Math.ceil(count / perPage),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
12
packages/prisma/types/document.ts
Normal file
12
packages/prisma/types/document.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Document, Recipient } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type DocumentWithRecipientAndSender = Omit<Document, 'document'> & {
|
||||||
|
recipient: Recipient;
|
||||||
|
sender: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
subject: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
@ -115,6 +115,11 @@ module.exports = {
|
|||||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
},
|
},
|
||||||
|
screens: {
|
||||||
|
'3xl': '1920px',
|
||||||
|
'4xl': '2560px',
|
||||||
|
'5xl': '3840px',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
|
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
|
||||||
|
|||||||
@ -1,17 +1,45 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import { findDocumentsWithRecipientAndSender } from '@documenso/lib/server-only/document/find-documents';
|
||||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||||
|
|
||||||
import { authenticatedProcedure, router } from '../trpc';
|
import { authenticatedProcedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
|
ZSearchInboxDocumentsParamsSchema,
|
||||||
ZSendDocumentMutationSchema,
|
ZSendDocumentMutationSchema,
|
||||||
ZSetFieldsForDocumentMutationSchema,
|
ZSetFieldsForDocumentMutationSchema,
|
||||||
ZSetRecipientsForDocumentMutationSchema,
|
ZSetRecipientsForDocumentMutationSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
export const documentRouter = router({
|
export const documentRouter = router({
|
||||||
|
searchInboxDocuments: authenticatedProcedure
|
||||||
|
.input(ZSearchInboxDocumentsParamsSchema)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { filter, query, cursor: page } = input;
|
||||||
|
|
||||||
|
return await findDocumentsWithRecipientAndSender({
|
||||||
|
email: ctx.session.email,
|
||||||
|
query,
|
||||||
|
signingStatus: filter,
|
||||||
|
orderBy: {
|
||||||
|
column: 'created',
|
||||||
|
direction: 'desc',
|
||||||
|
},
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Something went wrong. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
setRecipientsForDocument: authenticatedProcedure
|
setRecipientsForDocument: authenticatedProcedure
|
||||||
.input(ZSetRecipientsForDocumentMutationSchema)
|
.input(ZSetRecipientsForDocumentMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@ -2,6 +2,19 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const ZSearchInboxDocumentsParamsSchema = z.object({
|
||||||
|
filter: z
|
||||||
|
.union([z.literal('SIGNED'), z.literal('NOT_SIGNED'), z.undefined()])
|
||||||
|
.catch(() => undefined),
|
||||||
|
cursor: z.number().default(1),
|
||||||
|
query: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.catch(() => undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSearchInboxDocumentsParamsSchema = z.infer<typeof ZSearchInboxDocumentsParamsSchema>;
|
||||||
|
|
||||||
export const ZSetRecipientsForDocumentMutationSchema = z.object({
|
export const ZSetRecipientsForDocumentMutationSchema = z.object({
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
recipients: z.array(
|
recipients: z.array(
|
||||||
|
|||||||
Reference in New Issue
Block a user