mirror of
https://github.com/documenso/documenso.git
synced 2025-11-22 12:41:36 +10:00
fix: wip
This commit is contained in:
28
apps/remix/app/components/general/app-banner.tsx
Normal file
28
apps/remix/app/components/general/app-banner.tsx
Normal 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
|
||||
331
apps/remix/app/components/general/app-command-menu.tsx
Normal file
331
apps/remix/app/components/general/app-command-menu.tsx
Normal 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>
|
||||
));
|
||||
};
|
||||
96
apps/remix/app/components/general/app-header.tsx
Normal file
96
apps/remix/app/components/general/app-header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
94
apps/remix/app/components/general/app-nav-desktop.tsx
Normal file
94
apps/remix/app/components/general/app-nav-desktop.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
91
apps/remix/app/components/general/app-nav-mobile.tsx
Normal file
91
apps/remix/app/components/general/app-nav-mobile.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
71
apps/remix/app/components/general/avatar-with-recipient.tsx
Normal file
71
apps/remix/app/components/general/avatar-with-recipient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
apps/remix/app/components/general/background.tsx
Normal file
72
apps/remix/app/components/general/background.tsx
Normal file
File diff suppressed because one or more lines are too long
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
168
apps/remix/app/components/general/document/document-upload.tsx
Normal file
168
apps/remix/app/components/general/document/document-upload.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
288
apps/remix/app/components/general/menu-switcher.tsx
Normal file
288
apps/remix/app/components/general/menu-switcher.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
apps/remix/app/components/general/metric-card.tsx
Normal file
39
apps/remix/app/components/general/metric-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
70
apps/remix/app/components/general/period-selector.tsx
Normal file
70
apps/remix/app/components/general/period-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
apps/remix/app/components/general/refresh-on-focus.tsx
Normal file
21
apps/remix/app/components/general/refresh-on-focus.tsx
Normal 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;
|
||||
};
|
||||
35
apps/remix/app/components/general/settings-header.tsx
Normal file
35
apps/remix/app/components/general/settings-header.tsx
Normal 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" />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
115
apps/remix/app/components/general/settings-nav-desktop.tsx
Normal file
115
apps/remix/app/components/general/settings-nav-desktop.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
117
apps/remix/app/components/general/settings-nav-mobile.tsx
Normal file
117
apps/remix/app/components/general/settings-nav-mobile.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
55
apps/remix/app/components/general/stack-avatar.tsx
Normal file
55
apps/remix/app/components/general/stack-avatar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
166
apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
Normal file
166
apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
47
apps/remix/app/components/general/stack-avatars.tsx
Normal file
47
apps/remix/app/components/general/stack-avatars.tsx
Normal 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)}</>;
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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`,
|
||||
|
||||
55
apps/remix/app/components/general/template/template-type.tsx
Normal file
55
apps/remix/app/components/general/template/template-type.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
82
apps/remix/app/components/general/user-profile-skeleton.tsx
Normal file
82
apps/remix/app/components/general/user-profile-skeleton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
86
apps/remix/app/components/general/user-profile-timur.tsx
Normal file
86
apps/remix/app/components/general/user-profile-timur.tsx
Normal 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 I’m 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>
|
||||
);
|
||||
};
|
||||
134
apps/remix/app/components/general/verify-email-banner.tsx
Normal file
134
apps/remix/app/components/general/verify-email-banner.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user