This commit is contained in:
David Nguyen
2025-02-05 00:57:00 +11:00
parent 540cc5bfc1
commit 1057ae6d2a
105 changed files with 379 additions and 357 deletions

View File

@ -0,0 +1,28 @@
import { type TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
export type AppBannerProps = {
banner: TSiteSettingsBannerSchema;
};
export const AppBanner = ({ banner }: AppBannerProps) => {
if (!banner.enabled) {
return null;
}
return (
<div className="mb-2" style={{ background: banner.data.bgColor }}>
<div
className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium"
style={{ color: banner.data.textColor }}
>
<div className="flex items-center">
<span dangerouslySetInnerHTML={{ __html: banner.data.content }} />
</div>
</div>
</div>
);
};
// Banner
// Custom Text
// Custom Text with Custom Icon

View File

@ -0,0 +1,331 @@
import { useCallback, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router';
import { Theme, useTheme } from 'remix-themes';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import {
DOCUMENTS_PAGE_SHORTCUT,
SETTINGS_PAGE_SHORTCUT,
TEMPLATES_PAGE_SHORTCUT,
} from '@documenso/lib/constants/keyboard-shortcuts';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from '@documenso/ui/primitives/command';
import { useToast } from '@documenso/ui/primitives/use-toast';
const DOCUMENTS_PAGES = [
{
label: msg`All documents`,
path: '/documents?status=ALL',
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
},
{ label: msg`Draft documents`, path: '/documents?status=DRAFT' },
{
label: msg`Completed documents`,
path: '/documents?status=COMPLETED',
},
{ label: msg`Pending documents`, path: '/documents?status=PENDING' },
{ label: msg`Inbox documents`, path: '/documents?status=INBOX' },
];
const TEMPLATES_PAGES = [
{
label: msg`All templates`,
path: '/templates',
shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''),
},
];
const SETTINGS_PAGES = [
{
label: msg`Settings`,
path: '/settings',
shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''),
},
{ label: msg`Profile`, path: '/settings/profile' },
{ label: msg`Password`, path: '/settings/password' },
];
export type AppCommandMenuProps = {
open?: boolean;
onOpenChange?: (_open: boolean) => void;
};
export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
const { _ } = useLingui();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(() => open ?? false);
const [search, setSearch] = useState('');
const [pages, setPages] = useState<string[]>([]);
const { data: searchDocumentsData, isLoading: isSearchingDocuments } =
trpcReact.document.searchDocuments.useQuery(
{
query: search,
},
{
placeholderData: (previousData) => previousData,
// Do not batch this due to relatively long request time compared to
// other queries which are generally batched with this.
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const searchResults = useMemo(() => {
if (!searchDocumentsData) {
return [];
}
return searchDocumentsData.map((document) => ({
label: document.title,
path: document.path,
value: document.value,
}));
}, [searchDocumentsData]);
const currentPage = pages[pages.length - 1];
const toggleOpen = () => {
setIsOpen((isOpen) => !isOpen);
onOpenChange?.(!isOpen);
if (isOpen) {
setPages([]);
setSearch('');
}
};
const setOpen = useCallback(
(open: boolean) => {
setIsOpen(open);
onOpenChange?.(open);
if (!open) {
setPages([]);
setSearch('');
}
},
[onOpenChange],
);
const push = useCallback(
(path: string) => {
void navigate(path);
setOpen(false);
},
[setOpen],
);
const addPage = (page: string) => {
setPages((pages) => [...pages, page]);
setSearch('');
};
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]);
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true });
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates);
const handleKeyDown = (e: React.KeyboardEvent) => {
// Escape goes to previous page
// Backspace goes to previous page when search is empty
if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) {
e.preventDefault();
if (currentPage === undefined) {
setOpen(false);
}
setPages((pages) => pages.slice(0, -1));
}
};
return (
<CommandDialog
commandProps={{
onKeyDown: handleKeyDown,
}}
open={open}
onOpenChange={setOpen}
>
<CommandInput
value={search}
onValueChange={setSearch}
placeholder={_(msg`Type a command or search...`)}
/>
<CommandList>
{isSearchingDocuments ? (
<CommandEmpty>
<div className="flex items-center justify-center">
<span className="animate-spin">
<Loader />
</span>
</div>
</CommandEmpty>
) : (
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
)}
{!currentPage && (
<>
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Documents`)}>
<Commands push={push} pages={DOCUMENTS_PAGES} />
</CommandGroup>
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
<Commands push={push} pages={TEMPLATES_PAGES} />
</CommandGroup>
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
<Commands push={push} pages={SETTINGS_PAGES} />
</CommandGroup>
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
Change language
</CommandItem>
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('theme')}>
Change theme
</CommandItem>
</CommandGroup>
{searchResults.length > 0 && (
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
<Commands push={push} pages={searchResults} />
</CommandGroup>
)}
</>
)}
{currentPage === 'theme' && <ThemeCommands />}
{currentPage === 'language' && <LanguageCommands />}
</CommandList>
</CommandDialog>
);
}
const Commands = ({
push,
pages,
}: {
push: (_path: string) => void;
pages: { label: MessageDescriptor | string; path: string; shortcut?: string; value?: string }[];
}) => {
const { _ } = useLingui();
return pages.map((page, idx) => (
<CommandItem
className="-mx-2 -my-1 rounded-lg"
key={page.path + idx}
value={page.value ?? (typeof page.label === 'string' ? page.label : _(page.label))}
onSelect={() => push(page.path)}
>
{typeof page.label === 'string' ? page.label : _(page.label)}
{page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>}
</CommandItem>
));
};
const ThemeCommands = () => {
const { _ } = useLingui();
const [, setTheme] = useTheme();
const themes = [
{ label: msg`Light Mode`, theme: Theme.LIGHT, icon: Sun },
{ label: msg`Dark Mode`, theme: Theme.DARK, icon: Moon },
{ label: msg`System Theme`, theme: null, icon: Monitor },
] as const;
return themes.map((theme) => (
<CommandItem
key={theme.theme}
onSelect={() => setTheme(theme.theme)}
className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2"
>
<theme.icon className="mr-2" />
{_(theme.label)}
</CommandItem>
));
};
const LanguageCommands = () => {
const { i18n, _ } = useLingui();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const setLanguage = async (lang: string) => {
if (isLoading || lang === i18n.locale) {
return;
}
setIsLoading(true);
try {
await dynamicActivate(lang);
const formData = new FormData();
formData.append('lang', lang);
const response = await fetch('/api/locale', {
method: 'post',
body: formData,
});
if (!response.ok) {
throw new Error(response.statusText);
}
} catch (e) {
console.error(`Failed to set language: ${e}`);
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(msg`Unable to change the language at this time. Please try again later.`),
});
}
setIsLoading(false);
};
return Object.values(SUPPORTED_LANGUAGES).map((language) => (
<CommandItem
disabled={isLoading}
key={language.full}
onSelect={async () => setLanguage(language.short)}
className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2"
>
<CheckIcon
className={cn('mr-2 h-4 w-4', i18n.locale === language.short ? 'opacity-100' : 'opacity-0')}
/>
{language.full}
</CommandItem>
));
};

View File

@ -0,0 +1,96 @@
import { type HTMLAttributes, useEffect, useState } from 'react';
import type { User } from '@prisma/client';
import { MenuIcon, SearchIcon } from 'lucide-react';
import { Link, useLocation, useParams } from 'react-router';
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { getRootHref } from '@documenso/lib/utils/params';
import { cn } from '@documenso/ui/lib/utils';
import { BrandingLogo } from '~/components/general/branding-logo';
import { AppCommandMenu } from './app-command-menu';
import { AppNavDesktop } from './app-nav-desktop';
import { AppNavMobile } from './app-nav-mobile';
import { MenuSwitcher } from './menu-switcher';
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
user: User;
teams: TGetTeamsResponse;
};
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
const params = useParams();
const { pathname } = useLocation();
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
const isPathTeamUrl = (teamUrl: string) => {
if (!pathname || !pathname.startsWith(`/t/`)) {
return false;
}
return pathname.split('/')[2] === teamUrl;
};
const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
return (
<header
className={cn(
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
scrollY > 5 && 'border-b-border',
className,
)}
{...props}
>
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
<Link
to={`${getRootHref(params, { returnEmptyRootString: true })}/documents`}
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
>
<BrandingLogo className="h-6 w-auto" />
</Link>
<AppNavDesktop setIsCommandMenuOpen={setIsCommandMenuOpen} />
<div
className="flex gap-x-4 md:ml-8"
title={selectedTeam ? selectedTeam.name : (user.name ?? '')}
>
<MenuSwitcher user={user} teams={teams} />
</div>
<div className="flex flex-row items-center space-x-4 md:hidden">
<button onClick={() => setIsCommandMenuOpen(true)}>
<SearchIcon className="text-muted-foreground h-6 w-6" />
</button>
<button onClick={() => setIsHamburgerMenuOpen(true)}>
<MenuIcon className="text-muted-foreground h-6 w-6" />
</button>
<AppCommandMenu open={isCommandMenuOpen} onOpenChange={setIsCommandMenuOpen} />
<AppNavMobile
isMenuOpen={isHamburgerMenuOpen}
onMenuOpenChange={setIsHamburgerMenuOpen}
/>
</div>
</div>
</header>
);
};

View File

@ -0,0 +1,94 @@
import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Search } from 'lucide-react';
import { Link, useLocation, useParams } from 'react-router';
import { getRootHref } from '@documenso/lib/utils/params';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
const navigationLinks = [
{
href: '/documents',
label: msg`Documents`,
},
{
href: '/templates',
label: msg`Templates`,
},
];
export type AppNavDesktopProps = HTMLAttributes<HTMLDivElement> & {
setIsCommandMenuOpen: (value: boolean) => void;
};
export const AppNavDesktop = ({
className,
setIsCommandMenuOpen,
...props
}: AppNavDesktopProps) => {
const { _ } = useLingui();
const { pathname } = useLocation();
const params = useParams();
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
const rootHref = getRootHref(params, { returnEmptyRootString: true });
useEffect(() => {
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent);
setModifierKey(isMacOS ? '⌘' : 'Ctrl');
}, []);
return (
<div
className={cn(
'ml-8 hidden flex-1 items-center gap-x-12 md:flex md:justify-between',
className,
)}
{...props}
>
<div className="flex items-baseline gap-x-6">
{navigationLinks.map(({ href, label }) => (
<Link
key={href}
to={`${rootHref}${href}`}
className={cn(
'text-muted-foreground dark:text-muted-foreground/60 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 dark:text-muted-foreground': pathname?.startsWith(
`${rootHref}${href}`,
),
},
)}
>
{_(label)}
</Link>
))}
</div>
<Button
variant="outline"
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
onClick={() => setIsCommandMenuOpen(true)}
>
<div className="flex items-center">
<Search className="mr-2 h-5 w-5" />
<Trans>Search</Trans>
</div>
<div>
<div className="text-muted-foreground bg-muted flex items-center rounded-md px-1.5 py-0.5 text-xs tracking-wider">
{modifierKey}+K
</div>
</div>
</Button>
</div>
);
};

View File

@ -0,0 +1,91 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Link, useParams } from 'react-router';
import LogoImage from '@documenso/assets/logo.png';
import { authClient } from '@documenso/auth/client';
import { getRootHref } from '@documenso/lib/utils/params';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
export type AppNavMobileProps = {
isMenuOpen: boolean;
onMenuOpenChange?: (_value: boolean) => void;
};
export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps) => {
const { _ } = useLingui();
const params = useParams();
const handleMenuItemClick = () => {
onMenuOpenChange?.(false);
};
const rootHref = getRootHref(params, { returnEmptyRootString: true });
const menuNavigationLinks = [
{
href: `${rootHref}/documents`,
text: msg`Documents`,
},
{
href: `${rootHref}/templates`,
text: msg`Templates`,
},
{
href: '/settings/teams',
text: msg`Teams`,
},
{
href: '/settings/profile',
text: msg`Settings`,
},
];
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<SheetContent className="flex w-full max-w-[350px] flex-col">
<Link to="/" onClick={handleMenuItemClick}>
<img
src={LogoImage}
alt="Documenso Logo"
className="dark:invert"
width={170}
height={25}
/>
</Link>
<div className="mt-8 flex w-full flex-col items-start gap-y-4">
{menuNavigationLinks.map(({ href, text }) => (
<Link
key={href}
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
to={href}
onClick={() => handleMenuItemClick()}
>
{_(text)}
</Link>
))}
<button
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
onClick={async () => authClient.signOut()}
>
<Trans>Sign Out</Trans>
</button>
</div>
<div className="mt-auto flex w-full flex-col space-y-4 self-end">
<div className="w-fit">
<ThemeSwitcher />
</div>
<p className="text-muted-foreground text-sm">
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
</p>
</div>
</SheetContent>
</Sheet>
);
};

View File

@ -0,0 +1,71 @@
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Recipient } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = {
recipient: Recipient;
documentStatus: DocumentStatus;
};
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard();
const { _ } = useLingui();
const { toast } = useToast();
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
const onRecipientClick = () => {
if (!signingToken) {
return;
}
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The signing link has been copied to your clipboard.`),
});
});
};
return (
<div
className={cn('my-1 flex items-center gap-2', {
'cursor-pointer hover:underline': signingToken,
})}
role={signingToken ? 'button' : undefined}
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
onClick={onRecipientClick}
>
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div
className="text-muted-foreground text-sm"
title={
signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined
}
>
<p>{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
);
}

File diff suppressed because one or more lines are too long

View File

@ -20,7 +20,6 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
@ -34,6 +33,7 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
export type SigningPageViewProps = {
document: DocumentAndSender;

View File

@ -0,0 +1,26 @@
import React from 'react';
import { Badge } from '@documenso/ui/primitives/badge';
export type DocumentHistorySheetChangesProps = {
values: {
key: string | React.ReactNode;
value: string | React.ReactNode;
}[];
};
export const DocumentHistorySheetChanges = ({ values }: DocumentHistorySheetChangesProps) => {
return (
<Badge
className="text-muted-foreground mt-3 block w-full space-y-0.5 text-xs"
variant="neutral"
>
{values.map(({ key, value }, i) => (
<p key={typeof key === 'string' ? key : i}>
<span>{key}: </span>
<span className="font-normal">{value}</span>
</p>
))}
</Badge>
);
};

View File

@ -0,0 +1,388 @@
import { useMemo, useState } from 'react';
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { ArrowRightIcon, Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
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 { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
import { DocumentHistorySheetChanges } from './document-history-sheet-changes';
export type DocumentHistorySheetProps = {
documentId: number;
userId: number;
isMenuOpen?: boolean;
onMenuOpenChange?: (_value: boolean) => void;
children?: React.ReactNode;
};
export const DocumentHistorySheet = ({
documentId,
userId,
isMenuOpen,
onMenuOpenChange,
children,
}: DocumentHistorySheetProps) => {
const { _, i18n } = useLingui();
const [isUserDetailsVisible, setIsUserDetailsVisible] = useState(false);
const {
data,
isLoading,
isLoadingError,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
{
documentId,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
placeholderData: (previousData) => previousData,
},
);
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
const extractBrowser = (userAgent?: string | null) => {
if (!userAgent) {
return 'Unknown';
}
const parser = new UAParser(userAgent);
parser.setUA(userAgent);
const result = parser.getResult();
return result.browser.name;
};
/**
* Applies the following formatting for a given text:
* - Uppercase first lower, lowercase rest
* - Replace _ with spaces
*
* @param text The text to format
* @returns The formatted text
*/
const formatGenericText = (text?: string | null) => {
if (!text) {
return '';
}
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
};
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
{children && <SheetTrigger asChild>{children}</SheetTrigger>}
<SheetContent
sheetClass="backdrop-blur-none"
className="flex w-full max-w-[500px] flex-col overflow-y-auto p-0"
>
<div className="text-foreground px-6 pt-6">
<h1 className="text-lg font-medium">
<Trans>Document history</Trans>
</h1>
<button
className="text-muted-foreground text-sm"
onClick={() => setIsUserDetailsVisible(!isUserDetailsVisible)}
>
{isUserDetailsVisible ? (
<Trans>Hide additional information</Trans>
) : (
<Trans>Show additional information</Trans>
)}
</button>
</div>
{isLoading && (
<div className="flex h-full items-center justify-center">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
)}
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center">
<p className="text-foreground/80 text-sm">
<Trans>Unable to load document history</Trans>
</p>
<button
onClick={async () => refetch()}
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
>
<Trans>Click here to retry</Trans>
</button>
</div>
)}
{data && (
<ul
className={cn('divide-y border-t', {
'mb-4 border-b': !hasNextPage,
})}
>
{documentAuditLogs.map((auditLog) => (
<li className="px-4 py-2.5" key={auditLog.id}>
<div className="flex flex-row items-center">
<Avatar className="mr-2 h-9 w-9">
<AvatarFallback className="text-xs text-gray-400">
{(auditLog?.email ?? auditLog?.name ?? '?').slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<p className="text-foreground text-xs font-bold">
{formatDocumentAuditLogAction(_, auditLog, userId).description}
</p>
<p className="text-foreground/50 text-xs">
{DateTime.fromJSDate(auditLog.createdAt)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toFormat('d MMM, yyyy HH:MM a')}
</p>
</div>
</div>
{match(auditLog)
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM },
() => null,
)
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED },
({ data }) => {
const values = [
{
key: 'Email',
value: data.recipientEmail,
},
{
key: 'Role',
value: formatGenericText(data.recipientRole),
},
];
// Insert the name to the start of the array if available.
if (data.recipientName) {
values.unshift({
key: 'Name',
value: data.recipientName,
});
}
return <DocumentHistorySheetChanges values={values} />;
},
)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, ({ data }) => {
if (data.changes.length === 0) {
return null;
}
return (
<DocumentHistorySheetChanges
values={data.changes.map(({ type, from, to }) => ({
key: formatGenericText(type),
value: (
<span className="inline-flex flex-row items-center">
<span>{type === 'ROLE' ? formatGenericText(from) : from}</span>
<ArrowRightIcon className="h-4 w-4" />
<span>{type === 'ROLE' ? formatGenericText(to) : to}</span>
</span>
),
}))}
/>
);
})
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED },
({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Field',
value: formatGenericText(data.fieldType),
},
{
key: 'Recipient',
value: formatGenericText(data.fieldRecipientEmail),
},
]}
/>
),
)
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED },
({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Old',
value: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None',
},
{
key: 'New',
value: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None',
},
]}
/>
),
)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
if (data.changes.length === 0) {
return null;
}
return (
<DocumentHistorySheetChanges
values={data.changes.map((change) => ({
key: formatGenericText(change.type),
value: change.type === 'PASSWORD' ? '*********' : change.to,
}))}
/>
);
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Old',
value: data.from,
},
{
key: 'New',
value: data.to,
},
]}
/>
))
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED },
({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Old',
value: data.from,
},
{
key: 'New',
value: data.to,
},
]}
/>
),
)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Field inserted',
value: formatGenericText(data.field.type),
},
]}
/>
))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Field uninserted',
value: formatGenericText(data.field),
},
]}
/>
))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Type',
value: DOCUMENT_AUDIT_LOG_EMAIL_FORMAT[data.emailType].description,
},
{
key: 'Sent to',
value: data.recipientEmail,
},
]}
/>
))
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED },
({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Old',
value: data.from,
},
{
key: 'New',
value: data.to,
},
]}
/>
),
)
.exhaustive()}
{isUserDetailsVisible && (
<>
<div className="mb-1 mt-2 flex flex-row space-x-2">
<Badge variant="neutral" className="text-muted-foreground">
IP: {auditLog.ipAddress ?? 'Unknown'}
</Badge>
<Badge variant="neutral" className="text-muted-foreground">
Browser: {extractBrowser(auditLog.userAgent)}
</Badge>
</div>
</>
)}
</li>
))}
{hasNextPage && (
<div className="flex items-center justify-center py-4">
<Button
variant="outline"
loading={isFetchingNextPage}
onClick={async () => fetchNextPage()}
>
Show more
</Button>
</div>
)}
</ul>
)}
</SheetContent>
</Sheet>
);
};

View File

@ -15,6 +15,7 @@ import {
Trash2,
} from 'lucide-react';
import { Link } from 'react-router';
import { useNavigate } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
@ -33,7 +34,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DocumentPageViewDropdownProps = {
@ -49,6 +50,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
const { toast } = useToast();
const { _ } = useLingui();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -186,6 +188,9 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
open={isDeleteDialogOpen}
canManageDocument={canManageDocument}
onOpenChange={setDeleteDialogOpen}
onDelete={() => {
void navigate(documentsPath);
}}
/>
{isDuplicateDialogOpen && (

View File

@ -0,0 +1,171 @@
import { useState } from 'react';
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { DocumentMeta } from '@prisma/client';
import { FieldType, SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
import { P, match } from 'ts-pattern';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PopoverHover } from '@documenso/ui/primitives/popover';
export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[];
documentMeta?: DocumentMeta;
showFieldStatus?: boolean;
};
export const DocumentReadOnlyFields = ({
documentMeta,
fields,
showFieldStatus = true,
}: DocumentReadOnlyFieldsProps) => {
const { _ } = useLingui();
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
const handleHideField = (fieldId: string) => {
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
return (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
<FieldRootContainer
field={field}
key={field.id}
cardClassName="border-gray-300/50 !shadow-none backdrop-blur-[1px] bg-gray-50 ring-0 ring-offset-0"
>
<div className="absolute -right-3 -top-3">
<PopoverHover
trigger={
<Avatar className="dark:border-foreground h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
{extractInitials(field.recipient.name || field.recipient.email)}
</AvatarFallback>
</Avatar>
}
contentProps={{
className: 'relative flex w-fit flex-col p-4 text-sm',
}}
>
{showFieldStatus && (
<Badge
className="mx-auto mb-1 py-0.5"
variant={
field.recipient.signingStatus === SigningStatus.SIGNED
? 'default'
: 'secondary'
}
>
{field.recipient.signingStatus === SigningStatus.SIGNED ? (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
) : (
<>
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</>
)}
</Badge>
)}
<p className="text-center font-semibold">
<span>{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])} field</span>
</p>
<p className="text-muted-foreground mt-1 text-center text-xs">
{field.recipient.name
? `${field.recipient.name} (${field.recipient.email})`
: field.recipient.email}{' '}
</p>
<button
className="absolute right-0 top-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
onClick={() => handleHideField(field.secondaryId)}
title="Hide field"
>
<EyeOffIcon className="h-3 w-3" />
</button>
</PopoverHover>
</div>
<div className="text-muted-foreground dark:text-background/70 break-all text-sm">
{field.recipient.signingStatus === SigningStatus.SIGNED &&
match(field)
.with({ type: FieldType.SIGNATURE }, (field) =>
field.signature?.signatureImageAsBase64 ? (
<img
src={field.signature.signatureImageAsBase64}
alt="Signature"
className="h-full w-full object-contain dark:invert"
/>
) : (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl">
{field.signature?.typedSignature}
</p>
),
)
.with(
{
type: P.union(
FieldType.NAME,
FieldType.INITIALS,
FieldType.EMAIL,
FieldType.NUMBER,
FieldType.RADIO,
FieldType.CHECKBOX,
FieldType.DROPDOWN,
),
},
() => field.customText,
)
.with({ type: FieldType.TEXT }, () => field.customText.substring(0, 20) + '...')
.with({ type: FieldType.DATE }, () =>
convertToLocalSystemFormat(
field.customText,
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
),
)
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
.exhaustive()}
{field.recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<p
className={cn('text-muted-foreground text-lg duration-200', {
'font-signature sm:text-xl md:text-2xl':
field.type === FieldType.SIGNATURE ||
field.type === FieldType.FREE_SIGNATURE,
})}
>
{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])}
</p>
)}
</div>
</FieldRootContainer>
),
)}
</ElementVisible>
);
};

View File

@ -0,0 +1,148 @@
import { useEffect, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { useSearchParams } from 'react-router';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentRecipientLinkCopyDialogProps = {
trigger?: React.ReactNode;
recipients: Recipient[];
};
export const DocumentRecipientLinkCopyDialog = ({
trigger,
recipients,
}: DocumentRecipientLinkCopyDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [, copy] = useCopyToClipboard();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const [open, setOpen] = useState(false);
const actionSearchParam = searchParams?.get('action');
const onBulkCopy = async () => {
const generatedString = recipients
.filter((recipient) => recipient.role !== RecipientRole.CC)
.map((recipient) => `${recipient.email}\n${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`)
.join('\n\n');
await copy(generatedString).then(() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`All signing links have been copied to your clipboard.`),
});
});
};
useEffect(() => {
if (actionSearchParam === 'view-signing-links') {
setOpen(true);
updateSearchParams({ action: null });
}
}, [actionSearchParam, open, setOpen, updateSearchParams]);
return (
<Dialog open={open} onOpenChange={(value) => setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle className="pb-0.5">
<Trans>Copy Signing Links</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
You can copy and share these links to recipients so they can action the document.
</Trans>
</DialogDescription>
</DialogHeader>
<ul className="text-muted-foreground divide-y rounded-lg border">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
</li>
)}
{recipients.map((recipient) => (
<li key={recipient.id} className="flex items-center justify-between px-4 py-3 text-sm">
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
/>
{recipient.role !== RecipientRole.CC && (
<CopyTextButton
value={formatSigningLink(recipient.token)}
onCopySuccess={() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The signing link has been copied to your clipboard.`),
});
}}
badgeContentUncopied={
<p className="ml-1 text-xs">
<Trans>Copy</Trans>
</p>
}
badgeContentCopied={
<p className="ml-1 text-xs">
<Trans>Copied</Trans>
</p>
}
/>
)}
</li>
))}
</ul>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Close</Trans>
</Button>
</DialogClose>
<Button type="button" onClick={onBulkCopy}>
<Trans>Bulk Copy</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from 'react';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useNavigate, useSearchParams } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
const { _ } = useLingui();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [searchTerm, setSearchTerm] = useState(initialValue);
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
const handleSearch = useCallback(
(term: string) => {
const params = new URLSearchParams(searchParams?.toString() ?? '');
if (term) {
params.set('search', term);
} else {
params.delete('search');
}
void navigate(`?${params.toString()}`);
},
[searchParams],
);
useEffect(() => {
handleSearch(searchTerm);
}, [debouncedSearchTerm]);
return (
<Input
type="search"
placeholder={_(msg`Search documents...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
);
};

View File

@ -0,0 +1,79 @@
import type { HTMLAttributes } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { ExtendedDocumentStatus } from '@prisma/types/extended-document-status';
import { CheckCircle2, Clock, File } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils';
type FriendlyStatus = {
label: MessageDescriptor;
labelExtended: MessageDescriptor;
icon?: LucideIcon;
color: string;
};
export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
PENDING: {
label: msg`Pending`,
labelExtended: msg`Document pending`,
icon: Clock,
color: 'text-blue-600 dark:text-blue-300',
},
COMPLETED: {
label: msg`Completed`,
labelExtended: msg`Document completed`,
icon: CheckCircle2,
color: 'text-green-500 dark:text-green-300',
},
DRAFT: {
label: msg`Draft`,
labelExtended: msg`Document draft`,
icon: File,
color: 'text-yellow-500 dark:text-yellow-200',
},
INBOX: {
label: msg`Inbox`,
labelExtended: msg`Document inbox`,
icon: SignatureIcon,
color: 'text-muted-foreground',
},
ALL: {
label: msg`All`,
labelExtended: msg`Document All`,
color: 'text-muted-foreground',
},
};
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
status: ExtendedDocumentStatus;
inheritColor?: boolean;
};
export const DocumentStatus = ({
className,
status,
inheritColor,
...props
}: DocumentStatusProps) => {
const { _ } = useLingui();
const { label, icon: Icon, color } = FRIENDLY_STATUS_MAP[status];
return (
<span className={cn('flex items-center', className)} {...props}>
{Icon && (
<Icon
className={cn('mr-2 inline-block h-4 w-4', {
[color]: !inheritColor,
})}
/>
)}
{_(label)}
</span>
);
};

View File

@ -0,0 +1,168 @@
import { useMemo, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DocumentUploadDropzoneProps = {
className?: string;
};
export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const team = useOptionalCurrentTeam();
const navigate = useNavigate();
const analytics = useAnalytics();
const userTimezone =
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
DEFAULT_DOCUMENT_TIME_ZONE;
const { quota, remaining, refreshLimits } = useLimits();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
const disabledMessage = useMemo(() => {
if (remaining.documents === 0) {
return team
? msg`Document upload disabled due to unpaid invoices`
: msg`You have reached your document limit.`;
}
if (!user.emailVerified) {
return msg`Verify your email to upload documents.`;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [remaining.documents, user.emailVerified, team]);
const onFileDrop = async (file: File) => {
try {
setIsLoading(true);
// Todo
// const { type, data } = await putPdfFile(file);
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/file', {
method: 'POST',
body: formData,
})
.then(async (res) => res.json())
.catch((e) => {
console.error('Upload failed:', e);
throw new AppError('UPLOAD_FAILED');
});
// const { id: documentDataId } = await createDocumentData({
// type,
// data,
// });
const { id } = await createDocument({
title: file.name,
documentDataId: response.id, // todo
timezone: userTimezone,
});
void refreshLimits();
toast({
title: _(msg`Document uploaded`),
description: _(msg`Your document has been uploaded successfully.`),
duration: 5000,
});
analytics.capture('App: Document Uploaded', {
userId: user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
void navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`);
} catch (err) {
const error = AppError.parseError(err);
console.error(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
)
.otherwise(() => msg`An error occurred while uploading your document.`);
toast({
title: _(msg`Error`),
description: _(errorMessage),
variant: 'destructive',
duration: 7500,
});
} finally {
setIsLoading(false);
}
};
const onFileDropRejected = () => {
toast({
title: _(msg`Your document failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
duration: 5000,
variant: 'destructive',
});
};
return (
<div className={cn('relative', className)}>
<DocumentDropzone
className="h-[min(400px,50vh)]"
disabled={remaining.documents === 0 || !user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}
onDropRejected={onFileDropRejected}
/>
<div className="absolute -bottom-6 right-0">
{team?.id === undefined &&
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
<p className="text-muted-foreground/60 text-xs">
<Trans>
{remaining.documents} of {quota.documents} documents remaining this month.
</Trans>
</p>
)}
</div>
{isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
</div>
)}
</div>
);
};

View File

@ -0,0 +1,288 @@
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { User } from '@prisma/client';
import { motion } from 'framer-motion';
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
import { Link, useLocation } from 'react-router';
import { authClient } from '@documenso/auth/client';
import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
import { cn } from '@documenso/ui/lib/utils';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
const MotionLink = motion(Link);
export type MenuSwitcherProps = {
user: User;
teams: TGetTeamsResponse;
};
export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProps) => {
const { _ } = useLingui();
const { pathname } = useLocation();
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
const isUserAdmin = isAdmin(user);
const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, {
initialData: initialTeamsData,
});
const teams = teamsQueryResult && teamsQueryResult.length > 0 ? teamsQueryResult : null;
const isPathTeamUrl = (teamUrl: string) => {
if (!pathname || !pathname.startsWith(`/t/`)) {
return false;
}
return pathname.split('/')[2] === teamUrl;
};
const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
const formatAvatarFallback = (teamName?: string) => {
if (teamName !== undefined) {
return teamName.slice(0, 1).toUpperCase();
}
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
};
const formatSecondaryAvatarText = (team?: typeof selectedTeam) => {
if (!team) {
return _(msg`Personal Account`);
}
if (team.ownerUserId === user.id) {
return _(msg`Owner`);
}
return _(TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role]);
};
/**
* Formats the redirect URL so we can switch between documents and templates page
* seemlessly between teams and personal accounts.
*/
const formatRedirectUrlOnSwitch = (teamUrl?: string) => {
const baseUrl = teamUrl ? `/t/${teamUrl}/` : '/';
const currentPathname = (pathname ?? '/').replace(TEAM_URL_REGEX, '');
if (currentPathname === '/templates') {
return `${baseUrl}templates`;
}
return baseUrl;
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="menu-switcher"
variant="none"
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
>
<AvatarWithText
avatarSrc={formatAvatarUrl(
selectedTeam ? selectedTeam.avatarImageId : user.avatarImageId,
)}
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
primaryText={selectedTeam ? selectedTeam.name : user.name}
secondaryText={formatSecondaryAvatarText(selectedTeam)}
rightSideComponent={
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
}
textSectionClassName="hidden lg:flex"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className={cn('z-[60] ml-6 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
align="end"
forceMount
>
{teams ? (
<>
<DropdownMenuLabel>
<Trans>Personal</Trans>
</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link to={formatRedirectUrlOnSwitch()}>
<AvatarWithText
avatarSrc={formatAvatarUrl(user.avatarImageId)}
avatarFallback={formatAvatarFallback()}
primaryText={user.name}
secondaryText={formatSecondaryAvatarText()}
rightSideComponent={
!pathname?.startsWith(`/t/`) && (
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
)
}
/>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="mt-2" />
<DropdownMenuLabel>
<div className="flex flex-row items-center justify-between">
<p>
<Trans>Teams</Trans>
</p>
<div className="flex flex-row space-x-2">
<DropdownMenuItem asChild>
<Button
title={_(msg`Manage teams`)}
variant="ghost"
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
asChild
>
<Link to="/settings/teams">
<Settings2 className="h-4 w-4" />
</Link>
</Button>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Button
title={_(msg`Create team`)}
variant="ghost"
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
asChild
>
<Link to="/settings/teams?action=add-team">
<Plus className="h-4 w-4" />
</Link>
</Button>
</DropdownMenuItem>
</div>
</div>
</DropdownMenuLabel>
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
{teams.map((team) => (
<DropdownMenuItem asChild key={team.id}>
<MotionLink
initial="initial"
animate="initial"
whileHover="animate"
to={formatRedirectUrlOnSwitch(team.url)}
>
<AvatarWithText
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={formatAvatarFallback(team.name)}
primaryText={team.name}
textSectionClassName="w-[200px]"
secondaryText={
<div className="relative w-full">
<motion.span
className="overflow-hidden"
variants={{
initial: { opacity: 1, translateY: 0 },
animate: { opacity: 0, translateY: '100%' },
}}
>
{formatSecondaryAvatarText(team)}
</motion.span>
<motion.span
className="absolute inset-0"
variants={{
initial: { opacity: 0, translateY: '100%' },
animate: { opacity: 1, translateY: 0 },
}}
>{`/t/${team.url}`}</motion.span>
</div>
}
rightSideComponent={
isPathTeamUrl(team.url) && (
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
)
}
/>
</MotionLink>
</DropdownMenuItem>
))}
</div>
</>
) : (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link
to="/settings/teams?action=add-team"
className="flex items-center justify-between"
>
<Trans>Create team</Trans>
<Plus className="ml-2 h-4 w-4" />
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{isUserAdmin && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/admin">
<Trans>Admin panel</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/settings/profile">
<Trans>User settings</Trans>
</Link>
</DropdownMenuItem>
{selectedTeam &&
canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to={`/t/${selectedTeam.url}/settings/`}>
<Trans>Team settings</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-muted-foreground px-4 py-2"
onClick={() => setLanguageSwitcherOpen(true)}
>
<Trans>Language</Trans>
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive/90 hover:!text-destructive px-4 py-2"
onSelect={async () => authClient.signOut()}
>
<Trans>Sign Out</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
<LanguageSwitcherDialog open={languageSwitcherOpen} setOpen={setLanguageSwitcherOpen} />
</DropdownMenu>
);
};

View File

@ -0,0 +1,39 @@
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { cn } from '@documenso/ui/lib/utils';
export type CardMetricProps = {
icon?: LucideIcon;
title: string;
value: string | number;
className?: string;
};
export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricProps) => {
return (
<div
className={cn(
'border-border bg-background hover:shadow-border/80 h-32 max-h-32 max-w-full overflow-hidden rounded-lg border shadow shadow-transparent duration-200',
className,
)}
>
<div className="flex h-full max-h-full flex-col px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
<div className="flex items-start">
{Icon && (
<div className="mr-2 h-4 w-4">
<Icon className="text-muted-foreground h-4 w-4" />
</div>
)}
<h3 className="text-primary-forground mb-2 flex items-end text-sm font-medium leading-tight">
{title}
</h3>
</div>
<p className="text-foreground mt-auto text-4xl font-semibold leading-8">
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,70 @@
import { useMemo } from 'react';
import { Trans } from '@lingui/macro';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return ['', '7d', '14d', '30d'].includes(value as string);
};
export const PeriodSelector = () => {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const period = useMemo(() => {
const p = searchParams?.get('period') ?? 'all';
return isPeriodSelectorValue(p) ? p : 'all';
}, [searchParams]);
const onPeriodChange = (newPeriod: string) => {
if (!pathname) {
return;
}
const params = new URLSearchParams(searchParams?.toString());
params.set('period', newPeriod);
if (newPeriod === '' || newPeriod === 'all') {
params.delete('period');
}
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
};
return (
<Select defaultValue={period} onValueChange={onPeriodChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">
<Trans>All Time</Trans>
</SelectItem>
<SelectItem value="7d">
<Trans>Last 7 days</Trans>
</SelectItem>
<SelectItem value="14d">
<Trans>Last 14 days</Trans>
</SelectItem>
<SelectItem value="30d">
<Trans>Last 30 days</Trans>
</SelectItem>
</SelectContent>
</Select>
);
};

View File

@ -0,0 +1,21 @@
import { useCallback, useEffect } from 'react';
import { useRevalidator } from 'react-router';
export const RefreshOnFocus = () => {
const { revalidate } = useRevalidator();
const onFocus = useCallback(() => {
void revalidate();
}, [revalidate]);
useEffect(() => {
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
};
}, [onFocus]);
return null;
};

View File

@ -0,0 +1,35 @@
import React from 'react';
import { cn } from '@documenso/ui/lib/utils';
export type SettingsHeaderProps = {
title: string;
subtitle: string;
hideDivider?: boolean;
children?: React.ReactNode;
className?: string;
};
export const SettingsHeader = ({
children,
title,
subtitle,
className,
hideDivider,
}: SettingsHeaderProps) => {
return (
<>
<div className={cn('flex flex-row items-center justify-between', className)}>
<div>
<h3 className="text-lg font-medium">{title}</h3>
<p className="text-muted-foreground text-sm md:mt-2">{subtitle}</p>
</div>
{children}
</div>
{!hideDivider && <hr className="my-4" />}
</>
);
};

View File

@ -0,0 +1,115 @@
import type { HTMLAttributes } from 'react';
import { Trans } from '@lingui/macro';
import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react';
import { useLocation } from 'react-router';
import { Link } from 'react-router';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type SettingsDesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavProps) => {
const { pathname } = useLocation();
const isBillingEnabled = IS_BILLING_ENABLED();
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
<Link to="/settings/profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/profile') && 'bg-secondary',
)}
>
<User className="mr-2 h-5 w-5" />
<Trans>Profile</Trans>
</Button>
</Link>
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to="/settings/teams">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/teams') && 'bg-secondary',
)}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Teams</Trans>
</Button>
</Link>
<Link to="/settings/security">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/security') && 'bg-secondary',
)}
>
<Lock className="mr-2 h-5 w-5" />
<Trans>Security</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
)}
>
<Braces className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
{isBillingEnabled && (
<Link to="/settings/billing">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/billing') && 'bg-secondary',
)}
>
<CreditCard className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
</div>
);
};

View File

@ -0,0 +1,117 @@
import type { HTMLAttributes } from 'react';
import { Trans } from '@lingui/macro';
import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react';
import { Link, useLocation } from 'react-router';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type SettingsMobileNavProps = HTMLAttributes<HTMLDivElement>;
export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProps) => {
const { pathname } = useLocation();
const isBillingEnabled = IS_BILLING_ENABLED();
return (
<div
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
{...props}
>
<Link to="/settings/profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/profile') && 'bg-secondary',
)}
>
<User className="mr-2 h-5 w-5" />
<Trans>Profile</Trans>
</Button>
</Link>
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to="/settings/teams">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/teams') && 'bg-secondary',
)}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Teams</Trans>
</Button>
</Link>
<Link to="/settings/security">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/security') && 'bg-secondary',
)}
>
<Lock className="mr-2 h-5 w-5" />
<Trans>Security</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
)}
>
<Braces className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
{isBillingEnabled && (
<Link to="/settings/billing">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/billing') && 'bg-secondary',
)}
>
<CreditCard className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
</div>
);
};

View File

@ -0,0 +1,55 @@
import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
const ZIndexes: { [key: string]: string } = {
'10': 'z-10',
'20': 'z-20',
'30': 'z-30',
'40': 'z-40',
'50': 'z-50',
};
export type StackAvatarProps = {
first?: boolean;
zIndex?: string;
fallbackText?: string;
type: RecipientStatusType;
};
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
let classes = '';
let zIndexClass = '';
const firstClass = first ? '' : '-ml-3';
if (zIndex) {
zIndexClass = ZIndexes[zIndex] ?? '';
}
switch (type) {
case RecipientStatusType.UNSIGNED:
classes = 'bg-dawn-200 text-dawn-900';
break;
case RecipientStatusType.OPENED:
classes = 'bg-yellow-200 text-yellow-700';
break;
case RecipientStatusType.WAITING:
classes = 'bg-water text-water-700';
break;
case RecipientStatusType.COMPLETED:
classes = 'bg-documenso-200 text-documenso-800';
break;
case RecipientStatusType.REJECTED:
classes = 'bg-red-200 text-red-800';
break;
default:
break;
}
return (
<Avatar
className={` ${zIndexClass} ${firstClass} dark:border-border h-10 w-10 border-2 border-solid border-white`}
>
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
</Avatar>
);
};

View File

@ -0,0 +1,166 @@
import { useMemo } from 'react';
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { type DocumentStatus, type Recipient } from '@prisma/client';
import { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { AvatarWithRecipient } from './avatar-with-recipient';
import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
recipients: Recipient[];
position?: 'top' | 'bottom';
children?: React.ReactNode;
};
export const StackAvatarsWithTooltip = ({
documentStatus,
recipients,
position,
children,
}: StackAvatarsWithTooltipProps) => {
const { _ } = useLingui();
const waitingRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.WAITING,
);
const openedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.OPENED,
);
const completedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.COMPLETED,
);
const uncompletedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED,
);
const rejectedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
);
const sortedRecipients = useMemo(() => {
const otherRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
);
return [
...rejectedRecipients.sort((a, b) => a.id - b.id),
...otherRecipients.sort((a, b) => {
return a.id - b.id;
}),
];
}, [recipients]);
return (
<PopoverHover
trigger={children || <StackAvatars recipients={sortedRecipients} />}
contentProps={{
className: 'flex flex-col gap-y-5 py-2',
side: position,
}}
>
{completedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
<Trans>Completed</Trans>
</h1>
{completedRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
)}
{rejectedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
<Trans>Rejected</Trans>
</h1>
{rejectedRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
<Trans>Waiting</Trans>
</h1>
{waitingRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))}
</div>
)}
{openedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
<Trans>Opened</Trans>
</h1>
{openedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))}
</div>
)}
{uncompletedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
<Trans>Uncompleted</Trans>
</h1>
{uncompletedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))}
</div>
)}
</PopoverHover>
);
};

View File

@ -0,0 +1,47 @@
import React from 'react';
import type { Recipient } from '@prisma/client';
import {
getExtraRecipientsType,
getRecipientType,
} from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { StackAvatar } from './stack-avatar';
export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
const renderStackAvatars = (recipients: Recipient[]) => {
const zIndex = 50;
const itemsToRender = recipients.slice(0, 5);
const remainingItems = recipients.length - itemsToRender.length;
return itemsToRender.map((recipient: Recipient, index: number) => {
const first = index === 0;
if (index === 4 && remainingItems > 0) {
return (
<StackAvatar
key="extra-recipient"
first={first}
zIndex={String(zIndex - index * 10)}
type={getExtraRecipientsType(recipients.slice(4))}
fallbackText={`+${remainingItems + 1}`}
/>
);
}
return (
<StackAvatar
key={recipient.id}
first={first}
zIndex={String(zIndex - index * 10)}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
);
});
};
return <>{renderStackAvatars(recipients)}</>;
}

View File

@ -8,9 +8,9 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type TeamSettingsDesktopNavProps = HTMLAttributes<HTMLDivElement>;
export type TeamSettingsNavDesktopProps = HTMLAttributes<HTMLDivElement>;
export const TeamSettingsDesktopNav = ({ className, ...props }: TeamSettingsDesktopNavProps) => {
export const TeamSettingsNavDesktop = ({ className, ...props }: TeamSettingsNavDesktopProps) => {
const { pathname } = useLocation();
const params = useParams();

View File

@ -8,9 +8,9 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type TeamSettingsMobileNavProps = HTMLAttributes<HTMLDivElement>;
export type TeamSettingsNavMobileProps = HTMLAttributes<HTMLDivElement>;
export const TeamSettingsMobileNav = ({ className, ...props }: TeamSettingsMobileNavProps) => {
export const TeamSettingsNavMobile = ({ className, ...props }: TeamSettingsNavMobileProps) => {
const { pathname } = useLocation();
const params = useParams();

View File

@ -20,16 +20,17 @@ import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentSearch } from '~/components/(dashboard)/document-search/document-search';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import { DocumentStatus } from '~/components/formatter/document-status';
import { SearchParamSelector } from '~/components/forms/search-param-selector';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { DocumentsTableActionButton } from '~/components/tables/documents-table-action-button';
import { DocumentsTableActionDropdown } from '~/components/tables/documents-table-action-dropdown';
import { DataTableTitle } from '~/components/tables/documents-table-title';
import { useOptionalCurrentTeam } from '~/providers/team';
import { PeriodSelector } from '../period-selector';
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
DOCUMENT: msg`Document`,
TEMPLATE: msg`Template`,

View File

@ -0,0 +1,55 @@
import type { HTMLAttributes } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { TemplateType as TemplateTypePrisma } from '@prisma/client';
import { Globe2, Lock } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { cn } from '@documenso/ui/lib/utils';
type TemplateTypeIcon = {
label: MessageDescriptor;
icon?: LucideIcon;
color: string;
};
type TemplateTypes = (typeof TemplateTypePrisma)[keyof typeof TemplateTypePrisma];
const TEMPLATE_TYPES: Record<TemplateTypes, TemplateTypeIcon> = {
PRIVATE: {
label: msg`Private`,
icon: Lock,
color: 'text-blue-600 dark:text-blue-300',
},
PUBLIC: {
label: msg`Public`,
icon: Globe2,
color: 'text-green-500 dark:text-green-300',
},
};
export type TemplateTypeProps = HTMLAttributes<HTMLSpanElement> & {
type: TemplateTypes;
inheritColor?: boolean;
};
export const TemplateType = ({ className, type, inheritColor, ...props }: TemplateTypeProps) => {
const { _ } = useLingui();
const { label, icon: Icon, color } = TEMPLATE_TYPES[type];
return (
<span className={cn('flex items-center', className)} {...props}>
{Icon && (
<Icon
className={cn('mr-2 inline-block h-4 w-4', {
[color]: !inheritColor,
})}
/>
)}
{_(label)}
</span>
);
};

View File

@ -0,0 +1,82 @@
import { Trans } from '@lingui/macro';
import type { User } from '@prisma/client';
import { File, User2 } from 'lucide-react';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { VerifiedIcon } from '@documenso/ui/icons/verified';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type UserProfileSkeletonProps = {
className?: string;
user: Pick<User, 'name' | 'url'>;
rows?: number;
};
export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSkeletonProps) => {
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
return (
<div
className={cn(
'dark:bg-background flex flex-col items-center rounded-xl bg-neutral-100 p-4',
className,
)}
>
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm lowercase">
{baseUrl.host}/u/{user.url}
</div>
<div className="mt-4">
<div className="bg-primary/10 rounded-full p-1.5">
<div className="bg-background flex h-20 w-20 items-center justify-center rounded-full border-2">
<User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
</div>
</div>
</div>
<div className="mt-6">
<div className="flex items-center justify-center gap-x-2">
<h2 className="max-w-[12rem] truncate text-2xl font-semibold">{user.name}</h2>
<VerifiedIcon className="text-primary h-8 w-8" />
</div>
<div className="dark:bg-foreground/30 mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300" />
<div className="dark:bg-foreground/20 mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200" />
</div>
<div className="mt-8 w-full">
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
Documents
</div>
{Array(rows)
.fill(0)
.map((_, index) => (
<div
key={index}
className="bg-background flex items-center justify-between gap-x-6 p-4"
>
<div className="flex items-center gap-x-2">
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
<div className="space-y-2">
<div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
</div>
</div>
<div className="flex-shrink-0">
<Button type="button" size="sm" className="pointer-events-none w-32">
<Trans>Sign</Trans>
</Button>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,86 @@
import { Trans } from '@lingui/macro';
import { File } from 'lucide-react';
import timurImage from '@documenso/assets/images/timur.png';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { VerifiedIcon } from '@documenso/ui/icons/verified';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type UserProfileTimurProps = {
className?: string;
rows?: number;
};
export const UserProfileTimur = ({ className, rows = 2 }: UserProfileTimurProps) => {
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
return (
<div
className={cn(
'dark:bg-background flex flex-col items-center rounded-xl bg-neutral-100 p-4',
className,
)}
>
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm">
{baseUrl.host}/u/timur
</div>
<div className="mt-4">
<img
src={timurImage}
className="h-20 w-20 rounded-full"
alt="image of timur ercan founder of documenso"
/>
</div>
<div className="mt-6">
<div className="flex items-center justify-center gap-x-2">
<h2 className="text-2xl font-semibold">Timur Ercan</h2>
<VerifiedIcon className="text-primary h-8 w-8" />
</div>
<p className="text-muted-foreground mt-4 max-w-[40ch] text-center text-sm">
<Trans>Hey Im Timur</Trans>
</p>
<p className="text-muted-foreground mt-1 max-w-[40ch] text-center text-sm">
<Trans>Pick any of the following agreements below and start signing to get started</Trans>
</p>
</div>
<div className="mt-8 w-full">
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
<Trans>Documents</Trans>
</div>
{Array(rows)
.fill(0)
.map((_, index) => (
<div
key={index}
className="bg-background flex items-center justify-between gap-x-6 p-4"
>
<div className="flex items-center gap-x-2">
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
<div className="space-y-2">
<div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
</div>
</div>
<div className="flex-shrink-0">
<Button type="button" size="sm" className="pointer-events-none w-32">
<Trans>Sign</Trans>
</Button>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { AlertTriangle } from 'lucide-react';
import { ONE_DAY, ONE_SECOND } from '@documenso/lib/constants/time';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type VerifyEmailBannerProps = {
email: string;
};
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
// Todo
const { mutateAsync: sendConfirmationEmail, isPending } =
trpc.profile.sendConfirmationEmail.useMutation();
const onResendConfirmationEmail = async () => {
try {
setIsButtonDisabled(true);
await sendConfirmationEmail({ email: email });
toast({
title: _(msg`Success`),
description: _(msg`Verification email sent successfully.`),
});
setIsOpen(false);
setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT);
} catch (err) {
setIsButtonDisabled(false);
toast({
title: _(msg`Error`),
description: _(msg`Something went wrong while sending the confirmation email.`),
variant: 'destructive',
});
}
};
useEffect(() => {
// Check localStorage to see if we've recently automatically displayed the dialog
// if it was within the past 24 hours, don't show it again
// otherwise, show it again and update the localStorage timestamp
const emailVerificationDialogLastShown = localStorage.getItem(
'emailVerificationDialogLastShown',
);
if (emailVerificationDialogLastShown) {
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
if (Date.now() - lastShownTimestamp < ONE_DAY) {
return;
}
}
setIsOpen(true);
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
}, []);
return (
<>
<div className="bg-yellow-200 dark:bg-yellow-400">
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium text-yellow-900">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
<Trans>Verify your email address to unlock all features.</Trans>
</div>
<div>
<Button
variant="ghost"
className="h-auto px-2.5 py-1.5 text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500"
disabled={isButtonDisabled}
onClick={() => setIsOpen(true)}
size="sm"
>
{isButtonDisabled ? (
<Trans>Verification Email Sent</Trans>
) : (
<Trans>Verify Now</Trans>
)}
</Button>
</div>
</div>
</div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogTitle>
<Trans>Verify your email address</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
We've sent a confirmation email to <strong>{email}</strong>. Please check your inbox
and click the link in the email to verify your account.
</Trans>
</DialogDescription>
<div>
<Button
disabled={isButtonDisabled}
loading={isPending}
onClick={onResendConfirmationEmail}
>
{isPending ? <Trans>Sending...</Trans> : <Trans>Resend Confirmation Email</Trans>}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};