diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 8113ada52..d6a275a59 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -4,6 +4,7 @@ module.exports = { extends: ['@documenso/eslint-config'], rules: { '@next/next/no-img-element': 'off', + 'no-unreachable': 'error', }, settings: { next: { diff --git a/apps/web/public/static/early-supporter-badge.svg b/apps/web/public/static/early-supporter-badge.svg new file mode 100644 index 000000000..11efe2193 --- /dev/null +++ b/apps/web/public/static/early-supporter-badge.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/static/premium-user-badge.svg b/apps/web/public/static/premium-user-badge.svg new file mode 100644 index 000000000..0a448c4e7 --- /dev/null +++ b/apps/web/public/static/premium-user-badge.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx b/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx deleted file mode 100644 index c894113b6..000000000 --- a/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx +++ /dev/null @@ -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 ( - <> - -
- {user.url ? 'Update your profile' : 'Claim your profile'} - - {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.'} - -
- -
- -
-
- - - - ); -}; diff --git a/apps/web/src/app/(dashboard)/settings/profile/page.tsx b/apps/web/src/app/(dashboard)/settings/profile/page.tsx index 669c149b5..07b536a35 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx @@ -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() {
+ - -
diff --git a/apps/web/src/app/(dashboard)/settings/public-profile/page.tsx b/apps/web/src/app/(dashboard)/settings/public-profile/page.tsx new file mode 100644 index 000000000..f622b5636 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/public-profile/page.tsx @@ -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 ; +} diff --git a/apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx b/apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx new file mode 100644 index 000000000..90759f68e --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx @@ -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; +}; + +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 ( +
+ + + +
*:first-child]:text-muted-foreground': !isPublicProfileVisible, + '[&>*:last-child]:text-muted-foreground': isPublicProfileVisible, + }, + )} + > + Hide + + Show +
+
+ + + {isPublicProfileVisible ? ( + <> +

+ Profile is currently visible. +

+ +

Toggle the switch to hide your profile from the public.

+ + ) : ( + <> +

+ Profile is currently hidden. +

+ +

Toggle the switch to show your profile to the public.

+ + )} +
+
+
+ + + +
+ + Link template} + /> + + +
+ +
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/public-profile/public-templates-data-table.tsx b/apps/web/src/app/(dashboard)/settings/public-profile/public-templates-data-table.tsx new file mode 100644 index 000000000..692e01ac6 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/public-profile/public-templates-data-table.tsx @@ -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; +}; + +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 ( +
+
+ {/* Loading and error handling states. */} + {publicDirectTemplates.length === 0 && ( + <> + {isInitialLoading && + Array(3) + .fill(0) + .map((_, index) => ( +
+
+ + +
+ + +
+
+ + +
+ ))} + + {isLoadingError && ( +
+ Unable to load your public profile templates at this time + +
+ )} + + {!isInitialLoading && ( +
+ No public profile templates found + + Click here to get started + + } + /> +
+ )} + + )} + + {/* Public templates list. */} + {publicDirectTemplates.map((template) => ( +
+
+ + +
+

{template.publicTitle}

+

{template.publicDescription}

+
+
+ + + + + + + + Action + + void onCopyClick(template.directLink.token)}> + + Copy sharable link + + + { + setPublicTemplateDialogPayload({ + step: 'MANAGE', + templateId: template.id, + }); + }} + > + + Update + + + + setPublicTemplateDialogPayload({ + step: 'CONFIRM_DISABLE', + templateId: template.id, + }) + } + > + + Remove + + + +
+ ))} +
+ + { + if (!value) { + setPublicTemplateDialogPayload(null); + } + }} + /> +
+ ); +}; diff --git a/apps/web/src/app/(profile)/layout.tsx b/apps/web/src/app/(profile)/layout.tsx new file mode 100644 index 000000000..9bb26f5b2 --- /dev/null +++ b/apps/web/src/app/(profile)/layout.tsx @@ -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 ( + +
+ + +
{children}
+
+ + +
+ ); +} diff --git a/apps/web/src/app/(profile)/p/[url]/not-found.tsx b/apps/web/src/app/(profile)/p/[url]/not-found.tsx new file mode 100644 index 000000000..e954255b5 --- /dev/null +++ b/apps/web/src/app/(profile)/p/[url]/not-found.tsx @@ -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 ( +
+
+

404 Profile not found

+ +

Oops! Something went wrong.

+ +

+ The profile you are looking for could not be found. +

+ +
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/(profile)/p/[url]/page.tsx b/apps/web/src/app/(profile)/p/[url]/page.tsx new file mode 100644 index 000000000..f8d197fff --- /dev/null +++ b/apps/web/src/app/(profile)/p/[url]/page.tsx @@ -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 ( +
+
+ + {publicProfile.avatarImageId && ( + + )} + + + {extractInitials(publicProfile.name)} + + + +
+

{publicProfile.name}

+ + {publicProfile.badge && ( + + + Profile badge + + + + Profile badge + +
+

+ {BADGE_DATA[publicProfile.badge.type].name} +

+

+ Since {DateTime.fromJSDate(publicProfile.badge.since).toFormat('LLL ‘yy')} +

+
+
+
+ )} +
+ +
+ {(profile.bio ?? '').split('\n').map((line, index) => ( +

+ {line} +

+ ))} +
+
+ + {templates.length === 0 && ( +
+

+ It looks like {publicProfile.name} hasn't added any documents to their profile yet.{' '} + {!user?.id && ( + + While waiting for them to do so you can create your own Documenso account and get + started with document signing right away. + + )} + {'userId' in profile && user?.id === profile.userId && ( + + Go to your{' '} + + public profile settings + {' '} + to add documents. + + )} +

+
+ )} + + {templates.length > 0 && ( +
+ + + + + Documents + + + + + {templates.map((template) => ( + + +
+ + +
+
+

+ {template.publicTitle} +

+

+ {template.publicDescription} +

+
+ + +
+
+
+
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(profile)/profile-header.tsx b/apps/web/src/app/(profile)/profile-header.tsx new file mode 100644 index 000000000..b29569dce --- /dev/null +++ b/apps/web/src/app/(profile)/profile-header.tsx @@ -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 ; + } + + return ( +
5 && 'border-b-border', + )} + > +
+ + + + Documenso Logo + + +
+

+ Want your own public profile? + + Like to have your own public profile with agreements? + +

+ + +
+
+
+ ); +}; diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx index a86797191..88782b1e7 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx @@ -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 (
- + + +
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/public-profile/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/public-profile/page.tsx new file mode 100644 index 000000000..494faaa9b --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/public-profile/page.tsx @@ -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 ; +} diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx index 4895a61b3..760b9cad2 100644 --- a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx +++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx @@ -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" > +
{ const { getFlag } = useFeatureFlags(); const isBillingEnabled = getFlag('app_billing'); + const isPublicProfileEnabled = getFlag('app_public_profile'); return (
@@ -35,6 +36,21 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + {isPublicProfileEnabled && ( + + + + )} + + {isPublicProfileEnabled && ( + + + + )} + + {isPublicProfileEnabled && ( + + + + )} + + {isPublicProfileEnabled && ( + + + + )} + + )} +
+ + +
+ + + + + )} + /> + + + + ); +}; diff --git a/apps/web/src/components/forms/public-profile-form.tsx b/apps/web/src/components/forms/public-profile-form.tsx new file mode 100644 index 000000000..3607d7fe1 --- /dev/null +++ b/apps/web/src/components/forms/public-profile-form.tsx @@ -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; + +export type PublicProfileFormProps = { + className?: string; + profileUrl?: string | null; + teamUrl?: string; + onProfileUpdate: (data: TPublicProfileFormSchema) => Promise; + profile: UserProfile | TeamProfile; +}; +export const PublicProfileForm = ({ + className, + profileUrl, + profile, + teamUrl, + onProfileUpdate, +}: PublicProfileFormProps) => { + const { toast } = useToast(); + + const [, copy] = useCopyToClipboard(); + + const [copiedTimeout, setCopiedTimeout] = useState(null); + + const form = useForm({ + 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 ( +
+ +
+ ( + + Public profile URL + + + + + {teamUrl && ( +

+ You can update the profile URL by updating the team URL in the general settings + page. +

+ )} + +
+ {!form.formState.errors.url && ( +
+ {field.value ? ( +
+ +
+ ) : ( +

A unique URL to access your profile

+ )} +
+ )} + + +
+
+ )} + /> + + { + const remaningLength = MAX_PROFILE_BIO_LENGTH - (field.value || '').length; + const pluralWord = Math.abs(remaningLength) === 1 ? 'character' : 'characters'; + + return ( + + Bio + +