From 5514dad4d89de4b2d5c07836ce9e14a52044f356 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 6 Jun 2024 14:46:48 +1000 Subject: [PATCH 01/14] feat: add public profiles --- .env.example | 1 + .eslintrc.cjs | 1 + .../public/static/early-supporter-badge.svg | 33 ++ apps/web/public/static/premium-user-badge.svg | 9 + .../profile/claim-profile-alert-dialog.tsx | 46 -- .../app/(dashboard)/settings/profile/page.tsx | 3 - .../settings/public-profile/page.tsx | 14 + .../public-profile-page-view.tsx | 175 ++++++++ .../public-templates-data-table.tsx | 209 +++++++++ apps/web/src/app/(profile)/layout.tsx | 36 ++ .../src/app/(profile)/p/[url]/not-found.tsx | 32 ++ apps/web/src/app/(profile)/p/[url]/page.tsx | 122 +++++ apps/web/src/app/(profile)/profile-header.tsx | 69 +++ .../app/(teams)/t/[teamUrl]/settings/page.tsx | 2 +- .../settings/public-profile/page.tsx | 28 ++ .../settings/layout/desktop-nav.tsx | 18 +- .../settings/layout/mobile-nav.tsx | 18 +- .../(teams)/settings/layout/desktop-nav.tsx | 23 +- .../(teams)/settings/layout/mobile-nav.tsx | 23 +- .../components/forms/public-profile-form.tsx | 236 ++++++++++ .../manage-public-template-dialog.tsx | 423 ++++++++++++++++++ packages/lib/constants/billing.ts | 5 + packages/lib/constants/feature-flags.ts | 1 + .../profile/get-public-profile-by-url.ts | 162 +++++++ packages/lib/server-only/team/create-team.ts | 56 ++- .../team/get-team-public-profile.ts | 63 +++ .../team/update-team-public-profile.ts | 38 ++ .../server-only/template/find-templates.ts | 9 +- .../template/update-template-settings.ts | 34 +- .../user/get-user-public-profile.ts | 55 +++ .../server-only/user/update-public-profile.ts | 80 +++- packages/lib/utils/billing.ts | 12 + packages/lib/utils/public-profiles.ts | 15 + packages/prisma/schema.prisma | 22 +- packages/trpc/server/profile-router/router.ts | 10 +- packages/trpc/server/profile-router/schema.ts | 7 +- packages/trpc/server/team-router/router.ts | 39 +- packages/trpc/server/team-router/schema.ts | 9 + .../trpc/server/template-router/router.ts | 17 + .../trpc/server/template-router/schema.ts | 42 +- packages/tsconfig/tsconfig.json | 1 + packages/ui/primitives/textarea.tsx | 5 +- turbo.json | 1 + 43 files changed, 2067 insertions(+), 137 deletions(-) create mode 100644 apps/web/public/static/early-supporter-badge.svg create mode 100644 apps/web/public/static/premium-user-badge.svg delete mode 100644 apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/public-profile/page.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/public-profile/public-templates-data-table.tsx create mode 100644 apps/web/src/app/(profile)/layout.tsx create mode 100644 apps/web/src/app/(profile)/p/[url]/not-found.tsx create mode 100644 apps/web/src/app/(profile)/p/[url]/page.tsx create mode 100644 apps/web/src/app/(profile)/profile-header.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/settings/public-profile/page.tsx create mode 100644 apps/web/src/components/forms/public-profile-form.tsx create mode 100644 apps/web/src/components/templates/manage-public-template-dialog.tsx create mode 100644 packages/lib/server-only/profile/get-public-profile-by-url.ts create mode 100644 packages/lib/server-only/team/get-team-public-profile.ts create mode 100644 packages/lib/server-only/team/update-team-public-profile.ts create mode 100644 packages/lib/server-only/user/get-user-public-profile.ts create mode 100644 packages/lib/utils/public-profiles.ts diff --git a/.env.example b/.env.example index 4919f0053..82a5150a8 100644 --- a/.env.example +++ b/.env.example @@ -100,6 +100,7 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5 # [[STRIPE]] NEXT_PRIVATE_STRIPE_API_KEY= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= +NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_PRODUCT_ID= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID= 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..7d4fbe6f7 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx @@ -5,7 +5,6 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get- import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; import { ProfileForm } from '~/components/forms/profile'; -import { ClaimProfileAlertDialog } from './claim-profile-alert-dialog'; import { DeleteAccountDialog } from './delete-account-dialog'; export const metadata: Metadata = { @@ -21,8 +20,6 @@ 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..7af86553d --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx @@ -0,0 +1,175 @@ +'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 { 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 { 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) { + return updateTeamProfile({ + teamId: team.id, + ...data, + }); + } + + return updateUserProfile(data); + }; + + const togglePublicProfileVisibility = async (isVisible: boolean) => { + 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 +
+
+ + + +
+ + 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..cac1f3cb3 --- /dev/null +++ b/apps/web/src/app/(profile)/layout.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; +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(); + + let teams: GetTeamsResponse = []; + + if (user && session) { + teams = await getTeams({ userId: user.id }); + } + + 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..53cdee1aa --- /dev/null +++ b/apps/web/src/app/(profile)/p/[url]/page.tsx @@ -0,0 +1,122 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import { notFound, redirect } from 'next/navigation'; + +import { FileIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import { getPublicProfileByUrl } from '@documenso/lib/server-only/profile/get-public-profile-by-url'; +import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@documenso/ui/primitives/table'; + +export type PublicProfilePageProps = { + params: { + url: string; + }; +}; + +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 { profile, templates } = publicProfile; + + return ( +
+
+ + + {publicProfile.name.slice(0, 1).toUpperCase()} + + + +
+

{publicProfile.name}

+ + {publicProfile.badge && ( + Profile badge 'premium-user-badge.svg') + .with('EarlySupporter', () => 'early-supporter-badge.svg') + .exhaustive()}`} + height={24} + width={24} + /> + )} +
+ +
+ {profile.bio} +
+
+ + {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..5848e46c9 --- /dev/null +++ b/apps/web/src/app/(profile)/profile-header.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import Link from 'next/link'; + +import { PlusIcon } from 'lucide-react'; + +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', + )} + > +
+ + + + +
+

+ 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..60ba4d26d 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx @@ -35,7 +35,7 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro return (
- + ; +} diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index 94e366e27..208bedcab 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -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 (
@@ -35,6 +36,21 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + {isPublicProfileEnabled && ( + + + + )} + + {isPublicProfileEnabled && ( + + + + )} + + {isPublicProfileEnabled && ( + + + + )} + + {isPublicProfileEnabled && ( + + + + )} + +
+ ) : ( +

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 + +