feat: add public profiles (#1180)

## Description

Add public profiles

## Changes

- Add profiles settings page for users and teams
- Add profiles page `/p/<url>`

## Not completed

- Pending tests
- UI changes to promote public profiles (sign up, etc)
This commit is contained in:
Lucas Smith
2024-06-27 22:47:20 +10:00
committed by GitHub
50 changed files with 2781 additions and 164 deletions

View File

@ -1,46 +0,0 @@
'use client';
import { useState } from 'react';
import type { User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { ClaimPublicProfileDialogForm } from '~/components/forms/public-profile-claim-dialog';
export type ClaimProfileAlertDialogProps = {
className?: string;
user: User;
};
export const ClaimProfileAlertDialog = ({ className, user }: ClaimProfileAlertDialogProps) => {
const [open, setOpen] = useState(false);
return (
<>
<Alert
className={cn(
'flex flex-col items-center justify-between gap-4 p-6 md:flex-row',
className,
)}
variant="neutral"
>
<div>
<AlertTitle>{user.url ? 'Update your profile' : 'Claim your profile'}</AlertTitle>
<AlertDescription className="mr-2">
{user.url
? 'Profiles are coming soon! Update your profile username to reserve your corner of the signing revolution.'
: 'Profiles are coming soon! Claim your profile username now to reserve your corner of the signing revolution.'}
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Button onClick={() => setOpen(true)}>{user.url ? 'Update Now' : 'Claim Now'}</Button>
</div>
</Alert>
<ClaimPublicProfileDialogForm open={open} onOpenChange={setOpen} user={user} />
</>
);
};

View File

@ -3,9 +3,9 @@ import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { ProfileForm } from '~/components/forms/profile';
import { ClaimProfileAlertDialog } from './claim-profile-alert-dialog';
import { DeleteAccountDialog } from './delete-account-dialog';
export const metadata: Metadata = {
@ -19,10 +19,9 @@ export default async function ProfileSettingsPage() {
<div>
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
<AvatarImageForm className="mb-8 max-w-xl" user={user} />
<ProfileForm className="mb-8 max-w-xl" user={user} />
<ClaimProfileAlertDialog className="max-w-xl" user={user} />
<hr className="my-4 max-w-xl" />
<DeleteAccountDialog className="max-w-xl" user={user} />

View File

@ -0,0 +1,14 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
import { PublicProfilePageView } from './public-profile-page-view';
export default async function Page() {
const { user } = await getRequiredServerComponentSession();
const { profile } = await getUserPublicProfile({
userId: user.id,
});
return <PublicProfilePageView user={user} profile={profile} />;
}

View File

@ -0,0 +1,207 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import type {
Team,
TeamProfile,
TemplateDirectLink,
User,
UserProfile,
} from '@documenso/prisma/client';
import { TemplateType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Switch } from '@documenso/ui/primitives/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form';
import { PublicProfileForm } from '~/components/forms/public-profile-form';
import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog';
import { PublicTemplatesDataTable } from './public-templates-data-table';
export type PublicProfilePageViewOptions = {
user: User;
team?: Team;
profile: UserProfile | TeamProfile;
};
type DirectTemplate = FindTemplateRow & {
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
};
const userProfileText = {
settingsTitle: 'Public Profile',
settingsSubtitle: 'You can choose to enable or disable your profile for public view.',
templatesTitle: 'My templates',
templatesSubtitle:
'Show templates in your public profile for your audience to sign and get started quickly',
};
const teamProfileText = {
settingsTitle: 'Team Public Profile',
settingsSubtitle: 'You can choose to enable or disable your team profile for public view.',
templatesTitle: 'Team templates',
templatesSubtitle:
'Show templates in your team public profile for your audience to sign and get started quickly',
};
export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePageViewOptions) => {
const { toast } = useToast();
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const { data } = trpc.template.findTemplates.useQuery({
perPage: 100,
teamId: team?.id,
});
const { mutateAsync: updateUserProfile, isLoading: isUpdatingUserProfile } =
trpc.profile.updatePublicProfile.useMutation();
const { mutateAsync: updateTeamProfile, isLoading: isUpdatingTeamProfile } =
trpc.team.updateTeamPublicProfile.useMutation();
const isUpdating = isUpdatingUserProfile || isUpdatingTeamProfile;
const profileText = team ? teamProfileText : userProfileText;
const enabledPrivateDirectTemplates = useMemo(
() =>
(data?.templates ?? []).filter(
(template): template is DirectTemplate =>
template.directLink?.enabled === true && template.type !== TemplateType.PUBLIC,
),
[data],
);
const onProfileUpdate = async (data: TPublicProfileFormSchema) => {
if (team) {
await updateTeamProfile({
teamId: team.id,
...data,
});
} else {
await updateUserProfile(data);
}
if (data.enabled === undefined && !isPublicProfileVisible) {
setIsTooltipOpen(true);
}
};
const togglePublicProfileVisibility = async (isVisible: boolean) => {
setIsTooltipOpen(false);
if (isUpdating) {
return;
}
if (isVisible && !user.url) {
toast({
title: 'You must set a profile URL before enabling your public profile.',
variant: 'destructive',
});
return;
}
setIsPublicProfileVisible(isVisible);
try {
await onProfileUpdate({
enabled: isVisible,
});
} catch {
toast({
title: 'Something went wrong',
description: 'We were unable to set your public profile to public. Please try again.',
variant: 'destructive',
});
setIsPublicProfileVisible(!isVisible);
}
};
useEffect(() => {
setIsPublicProfileVisible(profile.enabled);
}, [profile.enabled]);
return (
<div className="max-w-2xl">
<SettingsHeader title={profileText.settingsTitle} subtitle={profileText.settingsSubtitle}>
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
<TooltipTrigger asChild>
<div
className={cn(
'text-muted-foreground/50 flex flex-row items-center justify-center space-x-2 text-xs',
{
'[&>*:first-child]:text-muted-foreground': !isPublicProfileVisible,
'[&>*:last-child]:text-muted-foreground': isPublicProfileVisible,
},
)}
>
<span>Hide</span>
<Switch
disabled={isUpdating}
checked={isPublicProfileVisible}
onCheckedChange={togglePublicProfileVisibility}
/>
<span>Show</span>
</div>
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-[40ch] space-y-2 py-2">
{isPublicProfileVisible ? (
<>
<p>
Profile is currently <strong>visible</strong>.
</p>
<p>Toggle the switch to hide your profile from the public.</p>
</>
) : (
<>
<p>
Profile is currently <strong>hidden</strong>.
</p>
<p>Toggle the switch to show your profile to the public.</p>
</>
)}
</TooltipContent>
</Tooltip>
</SettingsHeader>
<PublicProfileForm
profileUrl={team ? team.url : user.url}
teamUrl={team?.url}
profile={profile}
onProfileUpdate={onProfileUpdate}
/>
<div className="mt-4">
<SettingsHeader
title={profileText.templatesTitle}
subtitle={profileText.templatesSubtitle}
hideDivider={true}
className="mt-8 [&>*>h3]:text-base"
>
<ManagePublicTemplateDialog
directTemplates={enabledPrivateDirectTemplates}
trigger={<Button variant="outline">Link template</Button>}
/>
</SettingsHeader>
<div className="mt-6">
<PublicTemplatesDataTable />
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,209 @@
'use client';
import { useMemo, useState } from 'react';
import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import type { TemplateDirectLink } from '@documenso/prisma/client';
import { TemplateType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
type DirectTemplate = FindTemplateRow & {
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
};
export const PublicTemplatesDataTable = () => {
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const [, copy] = useCopyToClipboard();
const [publicTemplateDialogPayload, setPublicTemplateDialogPayload] = useState<{
step: 'MANAGE' | 'CONFIRM_DISABLE';
templateId: number;
} | null>(null);
const { data, isInitialLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery(
{
teamId: team?.id,
},
{
keepPreviousData: true,
},
);
const { directTemplates, publicDirectTemplates, privateDirectTemplates } = useMemo(() => {
const directTemplates = (data?.templates ?? []).filter(
(template): template is DirectTemplate => template.directLink?.enabled === true,
);
const publicDirectTemplates = directTemplates.filter(
(template) => template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
);
const privateDirectTemplates = directTemplates.filter(
(template) => template.directLink?.enabled === true && template.type === TemplateType.PRIVATE,
);
return {
directTemplates,
publicDirectTemplates,
privateDirectTemplates,
};
}, [data]);
const onCopyClick = async (token: string) =>
copy(formatDirectTemplatePath(token)).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The direct link has been copied to your clipboard',
});
});
return (
<div>
<div className="dark:divide-foreground/30 dark:border-foreground/30 mt-6 divide-y divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200">
{/* Loading and error handling states. */}
{publicDirectTemplates.length === 0 && (
<>
{isInitialLoading &&
Array(3)
.fill(0)
.map((_, index) => (
<div
key={index}
className="bg-background flex items-center justify-between gap-x-6 p-4"
>
<div className="flex gap-x-2">
<FileIcon className="text-muted-foreground/40 h-8 w-8" strokeWidth={1.5} />
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-48" />
</div>
</div>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
</div>
))}
{isLoadingError && (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
Unable to load your public profile templates at this time
<button
onClick={(e) => {
e.preventDefault();
void refetch();
}}
>
Click here to retry
</button>
</div>
)}
{!isInitialLoading && (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
No public profile templates found
<ManagePublicTemplateDialog
directTemplates={privateDirectTemplates}
trigger={
<button className="hover:text-muted-foreground/80 mt-1 text-xs">
Click here to get started
</button>
}
/>
</div>
)}
</>
)}
{/* Public templates list. */}
{publicDirectTemplates.map((template) => (
<div
key={template.id}
className="bg-background flex items-center justify-between gap-x-6 p-4"
>
<div className="flex gap-x-2">
<FileIcon
className="text-muted-foreground/40 h-8 w-8 flex-shrink-0"
strokeWidth={1.5}
/>
<div>
<p className="text-sm">{template.publicTitle}</p>
<p className="text-xs text-neutral-400">{template.publicDescription}</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="center" side="left">
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem onClick={() => void onCopyClick(template.directLink.token)}>
<LinkIcon className="mr-2 h-4 w-4" />
Copy sharable link
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setPublicTemplateDialogPayload({
step: 'MANAGE',
templateId: template.id,
});
}}
>
<EditIcon className="mr-2 h-4 w-4" />
Update
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
setPublicTemplateDialogPayload({
step: 'CONFIRM_DISABLE',
templateId: template.id,
})
}
>
<Trash2Icon className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
<ManagePublicTemplateDialog
directTemplates={directTemplates}
initialTemplateId={publicTemplateDialogPayload?.templateId}
initialStep={publicTemplateDialogPayload?.step}
isOpen={publicTemplateDialogPayload !== null}
onIsOpenChange={(value) => {
if (!value) {
setPublicTemplateDialogPayload(null);
}
}}
/>
</div>
);
};

View File

@ -0,0 +1,32 @@
import React from 'react';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
import { NextAuthProvider } from '~/providers/next-auth';
import { ProfileHeader } from './profile-header';
type PublicProfileLayoutProps = {
children: React.ReactNode;
};
export default async function PublicProfileLayout({ children }: PublicProfileLayoutProps) {
const { user, session } = await getServerComponentSession();
// I wouldn't typically do this but it's better than the `let` statement
const teams = user && session ? await getTeams({ userId: user.id }) : undefined;
return (
<NextAuthProvider session={session}>
<div className="min-h-screen">
<ProfileHeader user={user} teams={teams} />
<main className="my-8 px-4 md:my-12 md:px-8">{children}</main>
</div>
<RefreshOnFocus />
</NextAuthProvider>
);
}

View File

@ -0,0 +1,32 @@
'use client';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export default function NotFound() {
return (
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
<div>
<p className="text-muted-foreground font-semibold">404 Profile not found</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
<p className="text-muted-foreground mt-4 text-sm">
The profile you are looking for could not be found.
</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button asChild className="w-32">
<Link href="/">
<ChevronLeft className="mr-2 h-4 w-4" />
Go Back
</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,194 @@
import Image from 'next/image';
import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import { FileIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getPublicProfileByUrl } from '@documenso/lib/server-only/profile/get-public-profile-by-url';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type PublicProfilePageProps = {
params: {
url: string;
};
};
const BADGE_DATA = {
Premium: {
imageSrc: '/static/premium-user-badge.svg',
name: 'Premium',
},
EarlySupporter: {
imageSrc: '/static/early-supporter-badge.svg',
name: 'Early supporter',
},
};
export default async function PublicProfilePage({ params }: PublicProfilePageProps) {
const { url: profileUrl } = params;
if (!profileUrl) {
redirect('/');
}
const publicProfile = await getPublicProfileByUrl({
profileUrl,
}).catch(() => null);
if (!publicProfile || !publicProfile.profile.enabled) {
notFound();
}
const { user } = await getServerComponentSession();
const { profile, templates } = publicProfile;
return (
<div className="flex flex-col items-center justify-center py-4 sm:py-32">
<div className="flex flex-col items-center">
<Avatar className="dark:border-border h-24 w-24 border-2 border-solid">
{publicProfile.avatarImageId && (
<AvatarImage
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${publicProfile.avatarImageId}`}
/>
)}
<AvatarFallback className="text-sm text-gray-400">
{extractInitials(publicProfile.name)}
</AvatarFallback>
</Avatar>
<div className="mt-4 flex flex-row items-center justify-center">
<h2 className="text-xl font-semibold md:text-2xl">{publicProfile.name}</h2>
{publicProfile.badge && (
<Tooltip>
<TooltipTrigger>
<Image
className="ml-2 flex items-center justify-center"
alt="Profile badge"
src={BADGE_DATA[publicProfile.badge.type].imageSrc}
height={24}
width={24}
/>
</TooltipTrigger>
<TooltipContent className="flex flex-row items-start py-2 !pl-3 !pr-3.5">
<Image
className="mt-0.5"
alt="Profile badge"
src={BADGE_DATA[publicProfile.badge.type].imageSrc}
height={24}
width={24}
/>
<div className="ml-2">
<p className="text-foreground text-base font-semibold">
{BADGE_DATA[publicProfile.badge.type].name}
</p>
<p className="text-muted-foreground mt-0.5 text-sm">
Since {DateTime.fromJSDate(publicProfile.badge.since).toFormat('LLL yy')}
</p>
</div>
</TooltipContent>
</Tooltip>
)}
</div>
<div className="text-muted-foreground mt-4 space-y-1">
{(profile.bio ?? '').split('\n').map((line, index) => (
<p
key={index}
className="max-w-[60ch] whitespace-pre-wrap break-words text-center text-sm"
>
{line}
</p>
))}
</div>
</div>
{templates.length === 0 && (
<div className="mt-4 w-full max-w-xl border-t pt-4">
<p className="text-muted-foreground max-w-[60ch] whitespace-pre-wrap break-words text-center text-sm leading-relaxed">
It looks like {publicProfile.name} hasn't added any documents to their profile yet.{' '}
{!user?.id && (
<span className="mt-2 inline-block">
While waiting for them to do so you can create your own Documenso account and get
started with document signing right away.
</span>
)}
{'userId' in profile && user?.id === profile.userId && (
<span className="mt-2 inline-block">
Go to your{' '}
<Link href="/settings/public-profile" className="underline">
public profile settings
</Link>{' '}
to add documents.
</span>
)}
</p>
</div>
)}
{templates.length > 0 && (
<div className="mt-8 w-full max-w-xl rounded-md border">
<Table className="w-full" overflowHidden>
<TableHeader>
<TableRow>
<TableHead className="w-full rounded-tl-md bg-neutral-50 dark:bg-neutral-700">
Documents
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((template) => (
<TableRow key={template.id}>
<TableCell className="text-muted-foreground flex flex-col justify-between overflow-hidden text-sm sm:flex-row">
<div className="flex flex-1 items-start justify-start gap-2">
<FileIcon
className="text-muted-foreground/40 h-8 w-8 flex-shrink-0"
strokeWidth={1.5}
/>
<div className="flex flex-1 flex-col gap-4 overflow-hidden md:flex-row md:items-start md:justify-between">
<div>
<p className="text-foreground text-sm font-semibold leading-none">
{template.publicTitle}
</p>
<p className="text-muted-foreground mt-1 line-clamp-3 max-w-[70ch] whitespace-normal text-xs">
{template.publicDescription}
</p>
</div>
<Button asChild className="w-20">
<Link href={formatDirectTemplatePath(template.directLink.token)}>
Sign
</Link>
</Button>
</div>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,86 @@
'use client';
import { useEffect, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { PlusIcon } from 'lucide-react';
import LogoIcon from '@documenso/assets/logo_icon.png';
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import type { User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
import { Logo } from '~/components/branding/logo';
type ProfileHeaderProps = {
user?: User | null;
teams?: GetTeamsResponse;
};
export const ProfileHeader = ({ user, teams = [] }: ProfileHeaderProps) => {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
if (user) {
return <AuthenticatedHeader user={user} teams={teams} />;
}
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',
)}
>
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:px-8">
<Link
href="/"
className="focus-visible:ring-ring ring-offset-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
>
<Logo className="hidden h-6 w-auto sm:block" />
<Image
src={LogoIcon}
alt="Documenso Logo"
width={48}
height={48}
className="h-10 w-auto dark:invert sm:hidden"
/>
</Link>
<div className="flex flex-row items-center justify-center">
<p className="text-muted-foreground mr-4">
<span className="text-sm sm:hidden">Want your own public profile?</span>
<span className="hidden text-sm sm:block">
Like to have your own public profile with agreements?
</span>
</p>
<Button asChild variant="secondary">
<Link href="/signup">
<div className="hidden flex-row items-center sm:flex">
<PlusIcon className="mr-1 h-5 w-5" />
Create now
</div>
<span className="sm:hidden">Create</span>
</Link>
</Button>
</div>
</div>
</header>
);
};

View File

@ -13,6 +13,7 @@ import { AddTeamEmailDialog } from '~/components/(teams)/dialogs/add-team-email-
import { DeleteTeamDialog } from '~/components/(teams)/dialogs/delete-team-dialog';
import { TransferTeamDialog } from '~/components/(teams)/dialogs/transfer-team-dialog';
import { UpdateTeamForm } from '~/components/(teams)/forms/update-team-form';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { TeamEmailDropdown } from './team-email-dropdown';
import { TeamTransferStatus } from './team-transfer-status';
@ -35,7 +36,7 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro
return (
<div>
<SettingsHeader title="Team Profile" subtitle="Here you can edit your team's details." />
<SettingsHeader title="General settings" subtitle="Here you can edit your team's details." />
<TeamTransferStatus
className="mb-4"
@ -44,6 +45,8 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro
transferVerification={team.transferVerification}
/>
<AvatarImageForm className="mb-8" team={team} user={session.user} />
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
<section className="mt-6 space-y-6">

View File

@ -0,0 +1,28 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
import { PublicProfilePageView } from '~/app/(dashboard)/settings/public-profile/public-profile-page-view';
export type TeamsSettingsPublicProfilePageProps = {
params: {
teamUrl: string;
};
};
export default async function TeamsSettingsPublicProfilePage({
params,
}: TeamsSettingsPublicProfilePageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const { profile } = await getTeamPublicProfile({
userId: user.id,
teamId: team.id,
});
return <PublicProfilePageView user={user} team={team} profile={profile} />;
}

View File

@ -7,6 +7,7 @@ import { motion } from 'framer-motion';
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
import { signOut } from 'next-auth/react';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
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 { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
@ -99,6 +100,9 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
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={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${
selectedTeam ? selectedTeam.avatarImageId : user.avatarImageId
}`}
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
primaryText={selectedTeam ? selectedTeam.name : user.name}
secondaryText={formatSecondaryAvatarText(selectedTeam)}
@ -122,6 +126,11 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
<DropdownMenuItem asChild>
<Link href={formatRedirectUrlOnSwitch()}>
<AvatarWithText
avatarSrc={
user.avatarImageId
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${user.avatarImageId}`
: undefined
}
avatarFallback={formatAvatarFallback()}
primaryText={user.name}
secondaryText={formatSecondaryAvatarText()}
@ -180,10 +189,15 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
href={formatRedirectUrlOnSwitch(team.url)}
>
<AvatarWithText
avatarSrc={
team.avatarImageId
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`
: undefined
}
avatarFallback={formatAvatarFallback(team.name)}
primaryText={team.name}
secondaryText={
<div className="relative">
<div className="relative w-full">
<motion.span
className="overflow-hidden"
variants={{

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@ -19,6 +19,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
const isPublicProfileEnabled = getFlag('app_public_profile');
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
@ -35,6 +36,21 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
{isPublicProfileEnabled && (
<Link href="/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" />
Public Profile
</Button>
</Link>
)}
<Link href="/settings/teams">
<Button
variant="ghost"

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@ -19,6 +19,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
const isPublicProfileEnabled = getFlag('app_public_profile');
return (
<div
@ -38,6 +39,21 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
{isPublicProfileEnabled && (
<Link href="/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" />
Public Profile
</Button>
</Link>
)}
<Link href="/settings/teams">
<Button
variant="ghost"

View File

@ -5,8 +5,9 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import { Braces, CreditCard, Settings, Users, Webhook } from 'lucide-react';
import { Braces, CreditCard, Globe2Icon, Settings, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -17,9 +18,14 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const pathname = usePathname();
const params = useParams();
const { getFlag } = useFeatureFlags();
const isPublicProfileEnabled = getFlag('app_public_profile');
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
const settingsPath = `/t/${teamUrl}/settings`;
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
@ -37,6 +43,21 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
{isPublicProfileEnabled && (
<Link href={publicProfilePath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
Public Profile
</Button>
</Link>
)}
<Link href={membersPath}>
<Button
variant="ghost"

View File

@ -5,8 +5,9 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import { Braces, CreditCard, Key, User, Webhook } from 'lucide-react';
import { Braces, CreditCard, Globe2Icon, Key, User, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -17,9 +18,14 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
const pathname = usePathname();
const params = useParams();
const { getFlag } = useFeatureFlags();
const isPublicProfileEnabled = getFlag('app_public_profile');
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
const settingsPath = `/t/${teamUrl}/settings`;
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
@ -45,6 +51,21 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
{isPublicProfileEnabled && (
<Link href={publicProfilePath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
Public Profile
</Button>
</Link>
)}
<Link href={membersPath}>
<Button
variant="ghost"

View File

@ -0,0 +1,189 @@
'use client';
import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { ErrorCode, useDropzone } from 'react-dropzone';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { Team, User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZAvatarImageFormSchema = z.object({
bytes: z.string().nullish(),
});
export type TAvatarImageFormSchema = z.infer<typeof ZAvatarImageFormSchema>;
export type AvatarImageFormProps = {
className?: string;
user: User;
team?: Team;
};
export const AvatarImageForm = ({ className, user, team }: AvatarImageFormProps) => {
const { toast } = useToast();
const router = useRouter();
const { mutateAsync: setProfileImage } = trpc.profile.setProfileImage.useMutation();
const initials = extractInitials(team?.name || user.name || '');
const hasAvatarImage = useMemo(() => {
if (team) {
return team.avatarImageId !== null;
}
return user.avatarImageId !== null;
}, [team, user.avatarImageId]);
const avatarImageId = team ? team.avatarImageId : user.avatarImageId;
const form = useForm<TAvatarImageFormSchema>({
values: {
bytes: null,
},
resolver: zodResolver(ZAvatarImageFormSchema),
});
const { getRootProps, getInputProps } = useDropzone({
maxSize: 1024 * 1024,
accept: {
'image/*': ['.png', '.jpg', '.jpeg'],
},
multiple: false,
onDropAccepted: ([file]) => {
void file.arrayBuffer().then((buffer) => {
const contents = base64.encode(new Uint8Array(buffer));
form.setValue('bytes', contents);
void form.handleSubmit(onFormSubmit)();
});
},
onDropRejected: ([file]) => {
form.setError('bytes', {
type: 'onChange',
message: match(file.errors[0].code)
.with(ErrorCode.FileTooLarge, () => 'Uploaded file is too large')
.with(ErrorCode.FileTooSmall, () => 'Uploaded file is too small')
.with(ErrorCode.FileInvalidType, () => 'Uploaded file not an allowed file type')
.otherwise(() => 'An unknown error occurred'),
});
},
});
const onFormSubmit = async (data: TAvatarImageFormSchema) => {
try {
await setProfileImage({
bytes: data.bytes,
teamId: team?.id,
});
toast({
title: 'Avatar Updated',
description: 'Your avatar has been updated successfully.',
duration: 5000,
});
router.refresh();
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to update the avatar. Please try again later.',
});
}
}
};
return (
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
// onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="bytes"
render={() => (
<FormItem>
<FormLabel>Avatar</FormLabel>
<FormControl>
<div className="flex items-center gap-8">
<div className="relative">
<Avatar className="h-16 w-16 border-2 border-solid">
{avatarImageId && (
<AvatarImage
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${avatarImageId}`}
/>
)}
<AvatarFallback className="text-sm text-gray-400">
{initials}
</AvatarFallback>
</Avatar>
{hasAvatarImage && (
<button
type="button"
className="bg-background/70 text-destructive absolute inset-0 flex cursor-pointer items-center justify-center text-xs opacity-0 transition-opacity hover:opacity-100"
disabled={form.formState.isSubmitting}
onClick={() => void onFormSubmit({ bytes: null })}
>
Remove
</button>
)}
</div>
<Button
type="button"
variant="secondary"
size="sm"
{...getRootProps()}
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}
>
Upload Avatar
<input {...getInputProps()} />
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,265 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { motion } from 'framer-motion';
import { AnimatePresence } from 'framer-motion';
import { CheckSquareIcon, CopyIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { formatUserProfilePath } from '@documenso/lib/utils/public-profiles';
import type { TeamProfile, UserProfile } from '@documenso/prisma/client';
import {
MAX_PROFILE_BIO_LENGTH,
ZUpdatePublicProfileMutationSchema,
} from '@documenso/trpc/server/profile-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZPublicProfileFormSchema = ZUpdatePublicProfileMutationSchema.pick({
bio: true,
enabled: true,
url: true,
});
export type TPublicProfileFormSchema = z.infer<typeof ZPublicProfileFormSchema>;
export type PublicProfileFormProps = {
className?: string;
profileUrl?: string | null;
teamUrl?: string;
onProfileUpdate: (data: TPublicProfileFormSchema) => Promise<unknown>;
profile: UserProfile | TeamProfile;
};
export const PublicProfileForm = ({
className,
profileUrl,
profile,
teamUrl,
onProfileUpdate,
}: PublicProfileFormProps) => {
const { toast } = useToast();
const [, copy] = useCopyToClipboard();
const [copiedTimeout, setCopiedTimeout] = useState<NodeJS.Timeout | null>(null);
const form = useForm<TPublicProfileFormSchema>({
values: {
url: profileUrl ?? '',
bio: profile?.bio ?? '',
},
resolver: zodResolver(ZPublicProfileFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async (data: TPublicProfileFormSchema) => {
try {
await onProfileUpdate(data);
toast({
title: 'Success',
description: 'Your public profile has been updated.',
duration: 5000,
});
form.reset({
url: data.url,
bio: data.bio,
});
} catch (err) {
const error = AppError.parseError(err);
switch (error.code) {
case AppErrorCode.PREMIUM_PROFILE_URL:
case AppErrorCode.PROFILE_URL_TAKEN:
form.setError('url', {
type: 'manual',
message: error.message,
});
break;
default:
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to update your public profile. Please try again later.',
});
}
}
};
const onCopy = async () => {
await copy(formatUserProfilePath(form.getValues('url') ?? '')).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The profile link has been copied to your clipboard',
});
});
if (copiedTimeout) {
clearTimeout(copiedTimeout);
}
setCopiedTimeout(
setTimeout(() => {
setCopiedTimeout(null);
}, 2000),
);
};
return (
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile URL</FormLabel>
<FormControl>
<Input {...field} disabled={field.disabled || teamUrl !== undefined} />
</FormControl>
{teamUrl && (
<p className="text-muted-foreground text-xs">
You can update the profile URL by updating the team URL in the general settings
page.
</p>
)}
<div className="h-8">
{!form.formState.errors.url && (
<div className="text-muted-foreground h-8 text-sm">
{field.value ? (
<div>
<Button
type="button"
variant="none"
className="h-7 rounded bg-neutral-50 pl-2 pr-0.5 font-normal dark:border dark:border-neutral-500 dark:bg-neutral-600"
onClick={async () => onCopy()}
>
<p>
{formatUserProfilePath('').replace(/https?:\/\//, '')}
<span className="font-semibold">{field.value}</span>
</p>
<div className="ml-1 flex h-6 w-6 items-center justify-center rounded transition-all hover:bg-neutral-200 hover:active:bg-neutral-300 dark:hover:bg-neutral-500 dark:hover:active:bg-neutral-400">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={copiedTimeout ? 'copied' : 'copy'}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.1 } }}
className="absolute"
>
{copiedTimeout ? (
<CheckSquareIcon className="h-3.5 w-3.5" />
) : (
<CopyIcon className="h-3.5 w-3.5" />
)}
</motion.div>
</AnimatePresence>
</div>
</Button>
</div>
) : (
<p>A unique URL to access your profile</p>
)}
</div>
)}
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="bio"
render={({ field }) => {
const remaningLength = MAX_PROFILE_BIO_LENGTH - (field.value || '').length;
const pluralWord = Math.abs(remaningLength) === 1 ? 'character' : 'characters';
return (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={teamUrl ? 'Write about the team' : 'Write about yourself'}
/>
</FormControl>
{!form.formState.errors.bio && (
<p className="text-muted-foreground text-sm">
{remaningLength >= 0
? `${remaningLength} ${pluralWord} remaining`
: `${Math.abs(remaningLength)} ${pluralWord} over the limit`}
</p>
)}
<FormMessage />
</FormItem>
);
}}
/>
<div className="flex flex-row justify-end space-x-4">
<AnimatePresence>
{form.formState.isDirty && (
<motion.div
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
<Button type="button" variant="secondary" onClick={() => form.reset()}>
Reset
</Button>
</motion.div>
)}
</AnimatePresence>
<Button
type="submit"
className="transition-opacity"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
Update
</Button>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,429 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { P, match } from 'ts-pattern';
import { z } from 'zod';
import type { Template, TemplateDirectLink } from '@documenso/prisma/client';
import { TemplateType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import {
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,
MAX_TEMPLATE_PUBLIC_TITLE_LENGTH,
} from '@documenso/trpc/server/template-router/schema';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { LocaleDate } from '../formatter/locale-date';
export type ManagePublicTemplateDialogProps = {
directTemplates: (Template & {
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
})[];
initialTemplateId?: number | null;
initialStep?: ProfileTemplateStep;
trigger?: React.ReactNode;
isOpen?: boolean;
onIsOpenChange?: (value: boolean) => unknown;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdatePublicTemplateFormSchema = z.object({
publicTitle: z
.string()
.min(1, { message: 'Title is required' })
.max(MAX_TEMPLATE_PUBLIC_TITLE_LENGTH, {
message: `Title cannot be longer than ${MAX_TEMPLATE_PUBLIC_TITLE_LENGTH} characters`,
}),
publicDescription: z
.string()
.min(1, { message: 'Description is required' })
.max(MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH, {
message: `Description cannot be longer than ${MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH} characters`,
}),
});
type TUpdatePublicTemplateFormSchema = z.infer<typeof ZUpdatePublicTemplateFormSchema>;
type ProfileTemplateStep = 'SELECT_TEMPLATE' | 'MANAGE' | 'CONFIRM_DISABLE';
export const ManagePublicTemplateDialog = ({
directTemplates,
trigger,
initialTemplateId = null,
initialStep = 'SELECT_TEMPLATE',
isOpen = false,
onIsOpenChange,
...props
}: ManagePublicTemplateDialogProps) => {
const { toast } = useToast();
const [open, onOpenChange] = useState(isOpen);
const team = useOptionalCurrentTeam();
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(initialTemplateId);
const [currentStep, setCurrentStep] = useState<ProfileTemplateStep>(() => {
if (initialStep) {
return initialStep;
}
return selectedTemplateId ? 'MANAGE' : 'SELECT_TEMPLATE';
});
const form = useForm({
resolver: zodResolver(ZUpdatePublicTemplateFormSchema),
defaultValues: {
publicTitle: '',
publicDescription: '',
},
});
const { mutateAsync: updateTemplateSettings, isLoading: isUpdatingTemplateSettings } =
trpc.template.updateTemplateSettings.useMutation();
const setTemplateToPrivate = async (templateId: number) => {
try {
await updateTemplateSettings({
templateId,
teamId: team?.id,
data: {
type: TemplateType.PRIVATE,
},
});
toast({
title: 'Success',
description: 'Template has been removed from your public profile.',
duration: 5000,
});
handleOnOpenChange(false);
} catch {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to remove this template from your profile. Please try again later.',
});
}
};
const onFormSubmit = async ({
publicTitle,
publicDescription,
}: TUpdatePublicTemplateFormSchema) => {
if (!selectedTemplateId) {
return;
}
try {
await updateTemplateSettings({
templateId: selectedTemplateId,
teamId: team?.id,
data: {
type: TemplateType.PUBLIC,
publicTitle,
publicDescription,
},
});
toast({
title: 'Success',
description: 'Template has been updated.',
duration: 5000,
});
onOpenChange(false);
} catch {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to update the template. Please try again later.',
});
}
};
const selectedTemplate = useMemo(
() => directTemplates.find((template) => template.id === selectedTemplateId),
[directTemplates, selectedTemplateId],
);
const onManageStep = () => {
if (!selectedTemplate) {
return;
}
form.reset({
publicTitle: selectedTemplate.publicTitle,
publicDescription: selectedTemplate.publicDescription,
});
setCurrentStep('MANAGE');
};
const isLoading = isUpdatingTemplateSettings || form.formState.isSubmitting;
useEffect(() => {
const initialTemplate = directTemplates.find((template) => template.id === initialTemplateId);
if (initialTemplate) {
setSelectedTemplateId(initialTemplate.id);
form.reset({
publicTitle: initialTemplate.publicTitle,
publicDescription: initialTemplate.publicDescription,
});
} else {
setSelectedTemplateId(null);
}
const step = initialStep || (selectedTemplateId ? 'MANAGE' : 'SELECT_TEMPLATE');
setCurrentStep(step);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialTemplateId, initialStep, open, isOpen]);
const handleOnOpenChange = (value: boolean) => {
if (isLoading || typeof value !== 'boolean') {
return;
}
onOpenChange(value);
onIsOpenChange?.(value);
};
return (
<Dialog {...props} open={isOpen || open} onOpenChange={handleOnOpenChange}>
<fieldset disabled={isLoading} className="relative flex-shrink-0">
<DialogTrigger asChild>{trigger}</DialogTrigger>
<AnimateGenericFadeInOut motionKey={currentStep}>
{match({ templateId: selectedTemplateId, currentStep })
.with({ currentStep: 'SELECT_TEMPLATE' }, () => (
<DialogContent>
<DialogHeader>
<DialogTitle>{team?.name || 'Your'} direct signing templates</DialogTitle>
<DialogDescription>
Select a template you'd like to display on your {team && `team's`} public
profile
</DialogDescription>
</DialogHeader>
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>Template</TableHead>
<TableHead>Created</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{directTemplates.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="h-16 text-center">
<p className="text-muted-foreground">No valid direct templates found</p>
</TableCell>
</TableRow>
)}
{directTemplates.map((row) => (
<TableRow
className="w-full cursor-pointer"
key={row.id}
onClick={() => setSelectedTemplateId(row.id)}
>
<TableCell className="text-muted-foreground max-w-[30ch] text-sm">
{row.title}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
<LocaleDate date={row.createdAt} />
</TableCell>
<TableCell>
{selectedTemplateId === row.id ? (
<CheckCircle2Icon className="h-5 w-5 text-neutral-600 dark:text-neutral-200" />
) : (
<CircleIcon className="h-5 w-5 text-neutral-300 dark:text-neutral-600" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<Button
type="button"
disabled={selectedTemplateId === null}
onClick={() => onManageStep()}
>
Continue
</Button>
</DialogFooter>
</DialogContent>
))
.with({ templateId: P.number, currentStep: 'MANAGE' }, () => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>Configure template</DialogTitle>
<DialogDescription>Manage details for this public template</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
className="flex h-full flex-col space-y-4"
onSubmit={form.handleSubmit(onFormSubmit)}
>
<FormField
control={form.control}
name="publicTitle"
render={({ field }) => (
<FormItem>
<FormLabel required>Title</FormLabel>
<FormControl>
<Input placeholder="The public name for your template" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="publicDescription"
render={({ field }) => {
const remaningLength =
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH - (field.value || '').length;
const pluralWord =
Math.abs(remaningLength) === 1 ? 'character' : 'characters';
return (
<FormItem>
<FormLabel required>Description</FormLabel>
<FormControl>
<Textarea
placeholder="The public description that will be displayed with this template"
{...field}
/>
</FormControl>
{!form.formState.errors.publicDescription && (
<p className="text-muted-foreground text-sm">
{remaningLength >= 0
? `${remaningLength} ${pluralWord} remaining`
: `${Math.abs(remaningLength)} ${pluralWord} over the limit`}
</p>
)}
<FormMessage />
</FormItem>
);
}}
/>
<DialogFooter>
{selectedTemplate?.type === TemplateType.PUBLIC && (
<Button
variant="destructive"
className="mr-auto w-full sm:w-auto"
onClick={() => setCurrentStep('CONFIRM_DISABLE')}
>
Disable
</Button>
)}
<DialogClose asChild>
<Button variant="secondary">Close</Button>
</DialogClose>
<Button type="submit" loading={isUpdatingTemplateSettings}>
Update
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
))
.with({ templateId: P.number, currentStep: 'CONFIRM_DISABLE' }, ({ templateId }) => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
The template will be removed from your profile
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
loading={isUpdatingTemplateSettings}
onClick={() => void setTemplateToPrivate(templateId)}
>
Confirm
</Button>
</DialogFooter>
</DialogContent>
))
.otherwise(() => null)}
</AnimateGenericFadeInOut>
</fieldset>
</Dialog>
);
};

View File

@ -0,0 +1,34 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getAvatarImage } from '@documenso/lib/server-only/profile/get-avatar-image';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({
status: 'error',
message: 'Method not allowed',
});
}
const { id } = req.query;
if (typeof id !== 'string') {
return res.status(400).json({
status: 'error',
message: 'Missing id',
});
}
const result = await getAvatarImage({ id });
if (!result) {
return res.status(404).json({
status: 'error',
message: 'Not found',
});
}
res.setHeader('Content-Type', result.contentType);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.send(result.content);
}