mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 07:43:16 +10:00
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:
33
apps/web/public/static/early-supporter-badge.svg
Normal file
33
apps/web/public/static/early-supporter-badge.svg
Normal file
@ -0,0 +1,33 @@
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1080_12656)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.56772 0.890928C9.5882 -0.296974 11.4118 -0.296978 12.4323 0.890927L13.2272 1.81624C13.3589 1.96964 13.5596 2.0435 13.758 2.01166L14.955 1.81961C16.4917 1.57307 17.8887 2.75864 17.9154 4.33206L17.9363 5.55768C17.9398 5.76086 18.0465 5.94788 18.2188 6.0525L19.2578 6.68358C20.5916 7.49375 20.9083 9.31015 19.9288 10.5329L19.1659 11.4853C19.0394 11.6432 19.0023 11.8559 19.0678 12.048L19.4627 13.2069C19.9696 14.6947 19.0578 16.292 17.5304 16.5919L16.3406 16.8255C16.1434 16.8643 15.9798 17.0031 15.9079 17.1928L15.4738 18.3373C14.9166 19.8066 13.203 20.4374 11.8423 19.6741L10.7825 19.0796C10.6068 18.981 10.3932 18.981 10.2175 19.0796L9.15768 19.6741C7.79704 20.4374 6.08341 19.8066 5.52618 18.3373L5.09212 17.1928C5.02017 17.0031 4.8566 16.8643 4.65937 16.8255L3.46962 16.5919C1.94224 16.292 1.03044 14.6947 1.53734 13.2069L1.93219 12.048C1.99765 11.8559 1.96057 11.6432 1.8341 11.4853L1.07116 10.5329C0.0917119 9.31015 0.408373 7.49375 1.74223 6.68358L2.78123 6.0525C2.95348 5.94788 3.06024 5.76086 3.0637 5.55768L3.08456 4.33206C3.11133 2.75864 4.50829 1.57307 6.04498 1.81961L7.24197 2.01166C7.4404 2.0435 7.64105 1.96964 7.77282 1.81624L8.56772 0.890928Z" fill="url(#paint0_linear_1080_12656)"/>
|
||||
<g filter="url(#filter0_di_1080_12656)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.3714 14.5609C13.5195 14.6358 13.6925 14.5149 13.6642 14.3563L13.1163 11.2805L15.4388 9.10299C15.5586 8.9907 15.4925 8.79506 15.327 8.77192L12.1176 8.32508L10.681 5.52519C10.6069 5.38093 10.3931 5.38093 10.319 5.52519L8.88116 8.32354L5.673 8.77192C5.50748 8.79506 5.44139 8.9907 5.56116 9.10299L7.8843 11.2803L7.33579 14.3563C7.30752 14.5149 7.48055 14.6358 7.62859 14.5609L10.5014 13.1083L13.3714 14.5609Z" fill="#FFFCEB"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_di_1080_12656" x="5.33521" y="5.41699" width="10.6591" height="9.90853" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="0.164785" dy="0.411963"/>
|
||||
<feGaussianBlur stdDeviation="0.164785"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.414307 0 0 0 0 0.24341 0 0 0 0 0.0856598 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="BackgroundImageFix" result="effect1_dropShadow_1080_12656"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1080_12656" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="0.164785" dy="0.164785"/>
|
||||
<feGaussianBlur stdDeviation="0.0823927"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/>
|
||||
<feBlend mode="screen" in2="shape" result="effect2_innerShadow_1080_12656"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_1080_12656" x1="12.5596" y1="-9.0568e-08" x2="6.25112" y2="19.9592" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFE76A"/>
|
||||
<stop offset="1" stop-color="#E8C445"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_1080_12656">
|
||||
<rect width="20" height="20" fill="white" transform="translate(0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
9
apps/web/public/static/premium-user-badge.svg
Normal file
9
apps/web/public/static/premium-user-badge.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.54474 0.890944C9.57689 -0.296979 11.4213 -0.296983 12.4535 0.890943L13.2575 1.81628C13.3908 1.96967 13.5937 2.04354 13.7944 2.0117L15.0051 1.81965C16.5593 1.57309 17.9723 2.75869 17.9994 4.33214L18.0205 5.55778C18.024 5.76096 18.1319 5.94799 18.3061 6.05261L19.357 6.6837C20.7061 7.49389 21.0264 9.31032 20.0358 10.5331L19.2641 11.4855C19.1362 11.6434 19.0987 11.8561 19.1649 12.0482L19.5643 13.2072C20.077 14.695 19.1547 16.2923 17.6099 16.5922L16.4065 16.8258C16.207 16.8646 16.0416 17.0034 15.9688 17.1931L15.5298 18.3376C14.9662 19.8069 13.233 20.4378 11.8568 19.6745L10.7848 19.08C10.6071 18.9814 10.3911 18.9814 10.2134 19.08L9.14145 19.6745C7.76525 20.4378 6.03203 19.8069 5.46842 18.3376L5.0294 17.1931C4.95662 17.0034 4.79119 16.8646 4.5917 16.8258L3.38834 16.5922C1.8435 16.2923 0.921268 14.695 1.43397 13.2072L1.83334 12.0482C1.89954 11.8561 1.86204 11.6434 1.73412 11.4855L0.962455 10.5331C-0.0281913 9.31032 0.292091 7.49389 1.6412 6.6837L2.69209 6.05261C2.8663 5.94799 2.97428 5.76096 2.97778 5.55778L2.99888 4.33214C3.02596 2.75869 4.4389 1.57309 5.99315 1.81965L7.20383 2.0117C7.40454 2.04354 7.60747 1.96967 7.74076 1.81628L8.54474 0.890944ZM13.7062 9.20711C14.0968 8.81658 14.0968 8.18342 13.7062 7.79289C13.3157 7.40237 12.6825 7.40237 12.292 7.79289L9.49912 10.5858L8.70622 9.79289C8.3157 9.40237 7.68253 9.40237 7.29201 9.79289C6.90148 10.1834 6.90148 10.8166 7.29201 11.2071L8.43846 12.3536C9.02425 12.9393 9.97399 12.9393 10.5598 12.3536L13.7062 9.20711Z" fill="url(#paint0_linear_1080_12647)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_1080_12647" x1="12.5823" y1="-9.05696e-08" x2="6.33214" y2="20.0004" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#96D766"/>
|
||||
<stop offset="1" stop-color="#5AAE30"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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} />
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
32
apps/web/src/app/(profile)/layout.tsx
Normal file
32
apps/web/src/app/(profile)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/app/(profile)/p/[url]/not-found.tsx
Normal file
32
apps/web/src/app/(profile)/p/[url]/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
apps/web/src/app/(profile)/p/[url]/page.tsx
Normal file
194
apps/web/src/app/(profile)/p/[url]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
apps/web/src/app/(profile)/profile-header.tsx
Normal file
86
apps/web/src/app/(profile)/profile-header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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">
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
@ -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={{
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
189
apps/web/src/components/forms/avatar-image.tsx
Normal file
189
apps/web/src/components/forms/avatar-image.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
265
apps/web/src/components/forms/public-profile-form.tsx
Normal file
265
apps/web/src/components/forms/public-profile-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
34
apps/web/src/pages/api/avatar/[id].tsx
Normal file
34
apps/web/src/pages/api/avatar/[id].tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user