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