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} />;
}