diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index 487378823..6ce0ce2c0 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -4,7 +4,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get- import type { DocumentsPageViewProps } from './documents-page-view'; import { DocumentsPageView } from './documents-page-view'; -import { PublicProfileIntro } from './username-claim/public-profile-intro'; +import { UpcomingProfileClaimTeaser } from './upcoming-profile-claim-teaser'; export type DocumentsPageProps = { searchParams?: DocumentsPageViewProps['searchParams']; @@ -18,7 +18,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage const { user } = await getRequiredServerComponentSession(); return ( <> - + ); diff --git a/apps/web/src/app/(dashboard)/documents/upcoming-profile-claim-teaser.tsx b/apps/web/src/app/(dashboard)/documents/upcoming-profile-claim-teaser.tsx new file mode 100644 index 000000000..a2b3aea69 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/upcoming-profile-claim-teaser.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +import type { User } from '@documenso/prisma/client'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { ClaimPublicProfileDialogForm } from '~/components/forms/public-profile-claim-dialog'; + +export type UpcomingProfileClaimTeaserProps = { + user: User; +}; + +export const UpcomingProfileClaimTeaser = ({ user }: UpcomingProfileClaimTeaserProps) => { + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + const [claimed, setClaimed] = useState(false); + + const onOpenChange = useCallback( + (open: boolean) => { + if (!open && !claimed) { + toast({ + title: 'Claim your profile later', + description: 'You can claim your profile later on by going to your profile settings!', + }); + } + + setOpen(open); + localStorage.setItem('app.hasShownProfileClaimDialog', 'true'); + }, + [claimed, toast], + ); + + useEffect(() => { + const hasShownProfileClaimDialog = + localStorage.getItem('app.hasShownProfileClaimDialog') === 'true'; + + if (!user.url && !hasShownProfileClaimDialog) { + onOpenChange(true); + } + }, [onOpenChange, user.url]); + + return ( + setClaimed(true)} + user={user} + /> + ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/username-claim/public-profile-intro.tsx b/apps/web/src/app/(dashboard)/documents/username-claim/public-profile-intro.tsx deleted file mode 100644 index c14f6c99d..000000000 --- a/apps/web/src/app/(dashboard)/documents/username-claim/public-profile-intro.tsx +++ /dev/null @@ -1,225 +0,0 @@ -'use client'; - -import React, { useRef, useState } from 'react'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { BadgeCheck, File } from 'lucide-react'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; - -import Lucas from '@documenso/assets/images/Lucas.png'; -import Timur from '@documenso/assets/images/Timur.png'; -import type { User } from '@documenso/prisma/client'; -import { TRPCClientError } from '@documenso/trpc/client'; -import { trpc } from '@documenso/trpc/react'; -import { cn } from '@documenso/ui/lib/utils'; -import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardHeader } from '@documenso/ui/primitives/card'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; -import { Skeleton } from '@documenso/ui/primitives/skeleton'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -export const ZPublicProfileFormSchema = z.object({ - profileURL: z.string().trim().min(1, { message: 'Please enter a valid URL slug.' }), -}); - -export type TPublicProfileFormSchema = z.infer; - -export type PublicProfileIntroProps = { - user: User; -}; - -export const PublicProfileIntro = ({ user }: PublicProfileIntroProps) => { - const form = useForm({ - values: { - profileURL: user.profileURL || '', - }, - resolver: zodResolver(ZPublicProfileFormSchema), - }); - const textRef = useRef(null); - - const { toast } = useToast(); - const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation(); - const isSaving = form.formState.isSubmitting; - - const isProfileURLClaimed = user.profileURL ? false : true; - const [showClaimingDialog, setShowClaimingDialog] = useState(isProfileURLClaimed); - const [showClaimedDialog, setShowClaimedDialog] = useState(false); - - const onFormSubmit = async ({ profileURL }: TPublicProfileFormSchema) => { - try { - await updatePublicProfile({ - profileURL, - }); - setShowClaimingDialog(false); - setShowClaimedDialog(true); - } catch (err) { - if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { - toast({ - title: 'An error occurred', - description: err.message, - variant: 'destructive', - }); - } else { - toast({ - title: 'An unknown error occurred', - variant: 'destructive', - description: - 'We encountered an unknown error while attempting to save your details. Please try again later.', - }); - } - } - }; - - return ( - <> - - - - - Introducing public profile! - - - Reserve your Documenso public profile username - - - - - - - documenso.com/u/timur - - - - Timur - -
- Timur Ercan -
- - Hey I’m Timur
Pick any of the following agreements below and start signing to - get started -
-
- - Documents -
-
-
- -
- NDA.pdf - - Like to discuss about my work? - -
-
- -
-
-
- -
- -
- ( - - Public profile URL - - <> - -
- - - documenso.com/u/ - - -
- -
- -
- )} - /> -
- -
- -
-
- -
-
- - - - All set! - - We will let you know as soon as this feature is launched - - - - - - - documenso.com/u/lucas - - - - Timur - -
- Lucas Smith -
-
- - -
-
- - Documents -
-
-
- -
- NDA.pdf - - Like to discuss about my work? - -
-
- -
-
-
-
-
- - ); -}; 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 new file mode 100644 index 000000000..817fc8d61 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx @@ -0,0 +1,45 @@ +'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 ( + <> + +
+ Claim your profile + + Profiles are coming soon! Claim your profile URL 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 11cfc8515..669c149b5 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx @@ -5,6 +5,7 @@ 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 = { @@ -18,9 +19,13 @@ export default async function ProfileSettingsPage() {
- + - + + +
+ +
); } diff --git a/apps/web/src/app/(dashboard)/settings/public-profile/layout.tsx b/apps/web/src/app/(dashboard)/settings/public-profile/layout.tsx deleted file mode 100644 index bd7755cc4..000000000 --- a/apps/web/src/app/(dashboard)/settings/public-profile/layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export type PublicProfileSettingsLayout = { - children: React.ReactNode; -}; - -export default function PublicProfileSettingsLayout({ children }: PublicProfileSettingsLayout) { - return
{children}
; -} diff --git a/apps/web/src/app/(dashboard)/settings/public-profile/page.tsx b/apps/web/src/app/(dashboard)/settings/public-profile/page.tsx deleted file mode 100644 index bcac9471a..000000000 --- a/apps/web/src/app/(dashboard)/settings/public-profile/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import * as React from 'react'; - -import type { Metadata } from 'next'; -import Link from 'next/link'; - -import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; - -export const metadata: Metadata = { - title: 'Public Profile', -}; - -export default function PublicProfilePage() { - return ( - <> - - Coming soon! - - } - /> - - ); -} diff --git a/apps/web/src/app/(unauthenticated)/check-email/layout.tsx b/apps/web/src/app/(unauthenticated)/check-email/layout.tsx deleted file mode 100644 index de063b6d2..000000000 --- a/apps/web/src/app/(unauthenticated)/check-email/layout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; - -import Image from 'next/image'; - -import backgroundPattern from '@documenso/assets/images/background-pattern.png'; -import { Card } from '@documenso/ui/primitives/card'; - -import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header'; - -type CheckEmailLayoutProps = { - children: React.ReactNode; -}; - -export default function CheckEmailLayout({ children }: CheckEmailLayoutProps) { - return ( - <> - -
-
-
- background pattern -
- -
{children}
-
-
-
- - ); -} diff --git a/apps/web/src/app/(unauthenticated)/check-email/page.tsx b/apps/web/src/app/(unauthenticated)/check-email/page.tsx index 94b410a8e..01f2b389d 100644 --- a/apps/web/src/app/(unauthenticated)/check-email/page.tsx +++ b/apps/web/src/app/(unauthenticated)/check-email/page.tsx @@ -9,17 +9,19 @@ export const metadata: Metadata = { export default function ForgotPasswordPage() { return ( -
-

Email sent!

+
+
+

Email sent!

-

- A password reset email has been sent, if you have an account you should see it in your inbox - shortly. -

+

+ A password reset email has been sent, if you have an account you should see it in your + inbox shortly. +

- + +
); } diff --git a/apps/web/src/app/(unauthenticated)/forgot-password/layout.tsx b/apps/web/src/app/(unauthenticated)/forgot-password/layout.tsx deleted file mode 100644 index 0bee1ccbf..000000000 --- a/apps/web/src/app/(unauthenticated)/forgot-password/layout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; - -import Image from 'next/image'; - -import backgroundPattern from '@documenso/assets/images/background-pattern.png'; -import { Card } from '@documenso/ui/primitives/card'; - -import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header'; - -type ForgotPasswordLayoutProps = { - children: React.ReactNode; -}; - -export default function ForgotPasswordLayout({ children }: ForgotPasswordLayoutProps) { - return ( - <> - -
-
- background pattern -
-
- -
{children}
-
-
-
- - ); -} diff --git a/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx b/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx index 174f5714d..e93c8947c 100644 --- a/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx +++ b/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx @@ -9,22 +9,24 @@ export const metadata: Metadata = { export default function ForgotPasswordPage() { return ( -
-

Forgot your password?

+
+
+

Forgot your password?

-

- No worries, it happens! Enter your email and we'll email you a special link to reset your - password. -

+

+ No worries, it happens! Enter your email and we'll email you a special link to reset your + password. +

- + -

- Remembered your password?{' '} - - Sign In - -

+

+ Remembered your password?{' '} + + Sign In + +

+
); } diff --git a/apps/web/src/app/(unauthenticated)/layout.tsx b/apps/web/src/app/(unauthenticated)/layout.tsx new file mode 100644 index 000000000..03a73278f --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/layout.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import Image from 'next/image'; + +import backgroundPattern from '@documenso/assets/images/background-pattern.png'; + +type UnauthenticatedLayoutProps = { + children: React.ReactNode; +}; + +export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) { + return ( +
+
+
+ background pattern +
+ +
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/(unauthenticated)/reset-password/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/reset-password/[token]/page.tsx index 04afd2c4d..1d469eb74 100644 --- a/apps/web/src/app/(unauthenticated)/reset-password/[token]/page.tsx +++ b/apps/web/src/app/(unauthenticated)/reset-password/[token]/page.tsx @@ -19,19 +19,21 @@ export default async function ResetPasswordPage({ params: { token } }: ResetPass } return ( -
-

Reset Password

+
+
+

Reset Password

-

Please choose your new password

+

Please choose your new password

- + -

- Don't have an account?{' '} - - Sign up - -

+

+ Don't have an account?{' '} + + Sign up + +

+
); } diff --git a/apps/web/src/app/(unauthenticated)/reset-password/layout.tsx b/apps/web/src/app/(unauthenticated)/reset-password/layout.tsx deleted file mode 100644 index 087ef4168..000000000 --- a/apps/web/src/app/(unauthenticated)/reset-password/layout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; - -import Image from 'next/image'; - -import backgroundPattern from '@documenso/assets/images/background-pattern.png'; -import { Card } from '@documenso/ui/primitives/card'; - -import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header'; - -type ResetPasswordLayoutProps = { - children: React.ReactNode; -}; - -export default function ResetPasswordLayout({ children }: ResetPasswordLayoutProps) { - return ( - <> - -
-
-
- background pattern -
- -
{children}
-
-
-
- - ); -} diff --git a/apps/web/src/app/(unauthenticated)/reset-password/page.tsx b/apps/web/src/app/(unauthenticated)/reset-password/page.tsx index dc1a11089..20d4bfe57 100644 --- a/apps/web/src/app/(unauthenticated)/reset-password/page.tsx +++ b/apps/web/src/app/(unauthenticated)/reset-password/page.tsx @@ -9,17 +9,19 @@ export const metadata: Metadata = { export default function ResetPasswordPage() { return ( -
-

Unable to reset password

+
+
+

Unable to reset password

-

- The token you have used to reset your password is either expired or it never existed. If you - have still forgotten your password, please request a new reset link. -

+

+ The token you have used to reset your password is either expired or it never existed. If + you have still forgotten your password, please request a new reset link. +

- + +
); } diff --git a/apps/web/src/app/(unauthenticated)/signin/layout.tsx b/apps/web/src/app/(unauthenticated)/signin/layout.tsx deleted file mode 100644 index 6bea6e7ab..000000000 --- a/apps/web/src/app/(unauthenticated)/signin/layout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; - -import Image from 'next/image'; - -import backgroundPattern from '@documenso/assets/images/background-pattern.png'; -import { Card } from '@documenso/ui/primitives/card'; - -import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header'; - -type SignInLayoutProps = { - children: React.ReactNode; -}; - -export default function SignInLayout({ children }: SignInLayoutProps) { - return ( - <> - -
-
- background pattern -
-
- -
{children}
-
-
-
- - ); -} diff --git a/apps/web/src/app/(unauthenticated)/signin/page.tsx b/apps/web/src/app/(unauthenticated)/signin/page.tsx index 31baa502f..21136f2e6 100644 --- a/apps/web/src/app/(unauthenticated)/signin/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signin/page.tsx @@ -30,31 +30,27 @@ export default function SignInPage({ searchParams }: SignInPageProps) { } return ( - <> -
-

Sign in to your account

+
+
+

Sign in to your account

-

+

Welcome back, we are lucky to have you.

-
+
- + - {process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && ( + {NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (

Don't have an account?{' '} - + Sign up

)}
- +
); } diff --git a/apps/web/src/app/(unauthenticated)/signup/layout.tsx b/apps/web/src/app/(unauthenticated)/signup/layout.tsx deleted file mode 100644 index cbd6e8a37..000000000 --- a/apps/web/src/app/(unauthenticated)/signup/layout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; - -import { Card } from '@documenso/ui/primitives/card'; - -import ClaimUsernameCard from '../../../components/(dashboard)/claim-username-card/claim-username-card'; -import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header'; - -type SignUpLayoutProps = { - children: React.ReactNode; -}; - -export default function SignUpLayout({ children }: SignUpLayoutProps) { - return ( - <> - -
-
- - -
{children}
-
-
-
- - ); -} diff --git a/apps/web/src/app/(unauthenticated)/signup/page.tsx b/apps/web/src/app/(unauthenticated)/signup/page.tsx index a475e6064..0c1c1fae0 100644 --- a/apps/web/src/app/(unauthenticated)/signup/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signup/page.tsx @@ -1,13 +1,16 @@ import type { Metadata } from 'next'; +import Image from 'next/image'; import Link from 'next/link'; import { redirect } from 'next/navigation'; import { env } from 'next-runtime-env'; +import communityCardsImage from '@documenso/assets/images/community-cards.png'; import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { SignUpForm } from '~/components/forms/signup'; +import { UserProfileSkeleton } from '~/components/ui/user-profile-skeleton'; export const metadata: Metadata = { title: 'Sign Up', @@ -34,26 +37,57 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) { } return ( - <> -

Create a new account

+
+
+
+ community-cards +
-

- Create your account and start using state-of-the-art document signing. Open and beautiful - signing is within your grasp. -

+
- +
+
+ User profiles are coming soon! +
-

- Already have an account?{' '} - - Sign in instead - -

- + + +
+
+
+ +
+

Create a new account

+ +

+ Create your account and start using state-of-the-art document signing. Open and beautiful + signing is within your grasp. +

+ +
+ + + +

+ Already have an account?{' '} + + Sign in instead + +

+
+
); } diff --git a/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx index 634416fe3..289364ede 100644 --- a/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx +++ b/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx @@ -29,16 +29,18 @@ export default async function AcceptInvitationPage({ if (!teamMemberInvite) { return ( -
-

Invalid token

+
+
+

Invalid token

-

- This token is invalid or has expired. Please contact your team for a new invitation. -

+

+ This token is invalid or has expired. Please contact your team for a new invitation. +

- + +
); } diff --git a/apps/web/src/app/(unauthenticated)/team/layout.tsx b/apps/web/src/app/(unauthenticated)/team/layout.tsx deleted file mode 100644 index a6b2ddc62..000000000 --- a/apps/web/src/app/(unauthenticated)/team/layout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; - -import Image from 'next/image'; - -import backgroundPattern from '@documenso/assets/images/background-pattern.png'; -import { Card } from '@documenso/ui/primitives/card'; - -import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header'; - -type TeamLayoutProps = { - children: React.ReactNode; -}; - -export default function TeamLayout({ children }: TeamLayoutProps) { - return ( - <> - -
-
-
- background pattern -
- -
{children}
-
-
-
- - ); -} diff --git a/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx index 53ad4461b..8d67ca218 100644 --- a/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx +++ b/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx @@ -22,16 +22,18 @@ export default async function VerifyTeamEmailPage({ params: { token } }: VerifyT if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) { return ( -
-

Invalid link

+
+
+

Invalid link

-

- This link is invalid or has expired. Please contact your team to resend a verification. -

+

+ This link is invalid or has expired. Please contact your team to resend a verification. +

- + +
); } diff --git a/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx index 819b7e970..719ec5b76 100644 --- a/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx +++ b/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx @@ -25,17 +25,19 @@ export default async function VerifyTeamTransferPage({ if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) { return ( -
-

Invalid link

+
+
+

Invalid link

-

- This link is invalid or has expired. Please contact your team to resend a transfer - request. -

+

+ This link is invalid or has expired. Please contact your team to resend a transfer + request. +

- + +
); } diff --git a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx index f4b8b90d7..c5b6fbcff 100644 --- a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx +++ b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx @@ -4,23 +4,25 @@ import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation- export default function UnverifiedAccount() { return ( -
-
- -
-
-

Confirm email

+
+
+
+ +
+
+

Confirm email

-

- To gain access to your account, please confirm your email address by clicking on the - confirmation link from your inbox. -

+

+ To gain access to your account, please confirm your email address by clicking on the + confirmation link from your inbox. +

-

- If you don't find the confirmation link in your inbox, you can request a new one below. -

+

+ If you don't find the confirmation link in your inbox, you can request a new one below. +

- + +
); diff --git a/apps/web/src/app/(unauthenticated)/verify-email/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/verify-email/[token]/page.tsx index f671fb101..9536f937c 100644 --- a/apps/web/src/app/(unauthenticated)/verify-email/[token]/page.tsx +++ b/apps/web/src/app/(unauthenticated)/verify-email/[token]/page.tsx @@ -14,15 +14,17 @@ export type PageProps = { export default async function VerifyEmailPage({ params: { token } }: PageProps) { if (!token) { return ( -
-
- -
+
+
+
+ +
-

No token provided

-

- It seems that there is no token provided. Please check your email and try again. -

+

No token provided

+

+ It seems that there is no token provided. Please check your email and try again. +

+
); } @@ -31,22 +33,24 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps) if (verified === null) { return ( -
-
- -
+
+
+
+ +
-
-

Something went wrong

+
+

Something went wrong

-

- We were unable to verify your email. If your email is not verified already, please try - again. -

+

+ We were unable to verify your email. If your email is not verified already, please try + again. +

- + +
); @@ -54,17 +58,41 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps) if (!verified) { return ( +
+
+
+ +
+ +
+

Your token has expired!

+ +

+ It seems that the provided token has expired. We've just sent you another token, + please check your email and try again. +

+ + +
+
+
+ ); + } + + return ( +
- +
-

Your token has expired!

+

Email Confirmed!

- It seems that the provided token has expired. We've just sent you another token, please - check your email and try again. + Your email has been successfully confirmed! You can now use all features of Documenso.

- ); - } - - return ( -
-
- -
- -
-

Email Confirmed!

- -

- Your email has been successfully confirmed! You can now use all features of Documenso. -

- - -
); } diff --git a/apps/web/src/app/(unauthenticated)/verify-email/layout.tsx b/apps/web/src/app/(unauthenticated)/verify-email/layout.tsx deleted file mode 100644 index e1013720f..000000000 --- a/apps/web/src/app/(unauthenticated)/verify-email/layout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; - -import Image from 'next/image'; - -import backgroundPattern from '@documenso/assets/images/background-pattern.png'; -import { Card } from '@documenso/ui/primitives/card'; - -import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header'; - -type VerifyEmailLayoutProps = { - children: React.ReactNode; -}; - -export default function VerifyEmailLayout({ children }: VerifyEmailLayoutProps) { - return ( - <> - -
-
- background pattern -
-
- -
{children}
-
-
-
- - ); -} diff --git a/apps/web/src/app/(unauthenticated)/verify-email/page.tsx b/apps/web/src/app/(unauthenticated)/verify-email/page.tsx index 30d2baf16..f002ffda6 100644 --- a/apps/web/src/app/(unauthenticated)/verify-email/page.tsx +++ b/apps/web/src/app/(unauthenticated)/verify-email/page.tsx @@ -11,22 +11,26 @@ export const metadata: Metadata = { export default function EmailVerificationWithoutTokenPage() { return ( -
-
- -
+
+
+
+ +
-
-

Uh oh! Looks like you're missing a token

+
+

+ Uh oh! Looks like you're missing a token +

-

- It seems that there is no token provided, if you are trying to verify your email please - follow the link in your email. -

+

+ It seems that there is no token provided, if you are trying to verify your email please + follow the link in your email. +

- + +
); 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 08e258f4b..e87c47b67 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, Globe2, Lock, User, Users } from 'lucide-react'; +import { Braces, CreditCard, Lock, User, Users } from 'lucide-react'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; @@ -91,25 +91,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { )} - - - -
); }; diff --git a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx index c607fc175..ad5ca96f6 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx @@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { Braces, CreditCard, Globe2, Lock, User, Users } from 'lucide-react'; +import { Braces, CreditCard, Lock, User, Users } from 'lucide-react'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; @@ -94,25 +94,6 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => { )} - - - -
); }; diff --git a/apps/web/src/components/forms/public-profile-claim-dialog.tsx b/apps/web/src/components/forms/public-profile-claim-dialog.tsx new file mode 100644 index 000000000..54a602dee --- /dev/null +++ b/apps/web/src/components/forms/public-profile-claim-dialog.tsx @@ -0,0 +1,182 @@ +'use client'; + +import React, { useState } from 'react'; + +import Image from 'next/image'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import profileClaimTeaserImage from '@documenso/assets/images/profile-claim-teaser.png'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { User } 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 { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { UserProfileSkeleton } from '../ui/user-profile-skeleton'; + +export const ZClaimPublicProfileFormSchema = z.object({ + url: z.string().trim().min(1, { message: 'Please enter a valid URL slug.' }), +}); + +export type TClaimPublicProfileFormSchema = z.infer; + +export type ClaimPublicProfileDialogFormProps = { + open: boolean; + onOpenChange?: (open: boolean) => void; + onClaimed?: () => void; + user: User; +}; + +export const ClaimPublicProfileDialogForm = ({ + open, + onOpenChange, + onClaimed, + user, +}: ClaimPublicProfileDialogFormProps) => { + const { toast } = useToast(); + + const [claimed, setClaimed] = useState(false); + + const form = useForm({ + values: { + url: user.url || '', + }, + resolver: zodResolver(ZClaimPublicProfileFormSchema), + }); + + const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation(); + + const isSubmitting = form.formState.isSubmitting; + + const onFormSubmit = async ({ url }: TClaimPublicProfileFormSchema) => { + try { + await updatePublicProfile({ + url, + }); + + setClaimed(true); + onClaimed?.(); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.PROFILE_URL_TAKEN) { + form.setError('url', { + type: 'manual', + message: 'This URL is already taken', + }); + } else if (error.code !== AppErrorCode.UNKNOWN_ERROR) { + toast({ + title: 'An error occurred', + description: err.message, + variant: 'destructive', + }); + } else { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to save your details. Please try again later.', + }); + } + } + }; + + return ( + + + {!claimed && ( + <> + + + Introducing public profiles! + + + + Reserve your Documenso public profile username + + + + profile claim teaser + +
+ +
+ ( + + Public profile URL + + + + + + + +
+ documenso.com/u/{field.value || ''} +
+
+ )} + /> +
+ +
+ +
+
+ + + )} + + {claimed && ( + <> + + All set! + + + We will let you know as soon as this features is launched + + + + + +
+ +
+ + )} +
+
+ ); +}; diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 90184e402..1d6d32f1f 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -184,147 +184,147 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign }; return ( -
-
- -
- ( - - Email - - - - - - )} - /> + + +
+ ( + + Email - ( - - Password - - <> - -

- - Forgot your password? - -

- -
- -
- )} - /> -
+ + + - + + + )} + /> - {isGoogleSSOEnabled && ( - <> -
-
- Or continue with -
-
+ ( + + Password - - - )} - + + + - - - - Two-Factor Authentication - - -
-
- {twoFactorAuthenticationMethod === 'totp' && ( - ( - - Authentication Token - - - - - - )} - /> - )} - - {twoFactorAuthenticationMethod === 'backup' && ( - ( - - Backup Code - - - - - - )} - /> - )} - - - + Forgot your password? + +

+ + + )} + /> +
- - -
- - - - -
+ + + {isGoogleSSOEnabled && ( + <> +
+
+ Or continue with +
+
+ + + + )} + + + + + + Two-Factor Authentication + + +
+
+ {twoFactorAuthenticationMethod === 'totp' && ( + ( + + Authentication Token + + + + + + )} + /> + )} + + {twoFactorAuthenticationMethod === 'backup' && ( + ( + + Backup Code + + + + + + )} + /> + )} + + + + + + +
+
+
+
+ ); }; diff --git a/apps/web/src/components/ui/user-profile-skeleton.tsx b/apps/web/src/components/ui/user-profile-skeleton.tsx new file mode 100644 index 000000000..9b5ce1f61 --- /dev/null +++ b/apps/web/src/components/ui/user-profile-skeleton.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { File } from 'lucide-react'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import type { User } from '@documenso/prisma/client'; +import { VerifiedIcon } from '@documenso/ui/icons/verified'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; + +export type UserProfileSkeletonProps = { + className?: string; + user: Pick; + rows?: number; +}; + +export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSkeletonProps) => { + const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'); + + return ( +
+
+ {baseUrl.host}/u/{user.url} +
+ +
+
+ + + {user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase()} + + +
+
+ +
+
+

{user.name}

+ + +
+ +
+
+
+ +
+
+
+ Documents +
+ + {Array(rows) + .fill(0) + .map((_, index) => ( +
+
+ + +
+
+
+
+
+ +
+ +
+
+ ))} +
+
+
+ ); +}; diff --git a/packages/assets/images/community-cards.png b/packages/assets/images/community-cards.png new file mode 100644 index 000000000..fe9b7edb4 Binary files /dev/null and b/packages/assets/images/community-cards.png differ diff --git a/packages/assets/images/profile-claim-teaser.png b/packages/assets/images/profile-claim-teaser.png new file mode 100644 index 000000000..b388de0d2 Binary files /dev/null and b/packages/assets/images/profile-claim-teaser.png differ diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index 3337bab4c..bc2db70c2 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -18,6 +18,7 @@ export enum AppErrorCode { 'RETRY_EXCEPTION' = 'RetryException', 'SCHEMA_FAILED' = 'SchemaFailed', 'TOO_MANY_REQUESTS' = 'TooManyRequests', + 'PROFILE_URL_TAKEN' = 'ProfileUrlTaken', } const genericErrorCodeToTrpcErrorCodeMap: Record = { @@ -32,6 +33,7 @@ const genericErrorCodeToTrpcErrorCodeMap: Record = { [AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR', [AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR', [AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS', + [AppErrorCode.PROFILE_URL_TAKEN]: 'BAD_REQUEST', }; export const ZAppErrorJsonSchema = z.object({ diff --git a/packages/lib/server-only/user/update-public-profile.ts b/packages/lib/server-only/user/update-public-profile.ts index 910932e8c..0aebe3ecf 100644 --- a/packages/lib/server-only/user/update-public-profile.ts +++ b/packages/lib/server-only/user/update-public-profile.ts @@ -1,35 +1,49 @@ import { prisma } from '@documenso/prisma'; -import type { User, UserProfile } from '@documenso/prisma/client'; -import { getUserById } from './get-user-by-id'; +import { AppError, AppErrorCode } from '../../errors/app-error'; export type UpdatePublicProfileOptions = { - id: User['id']; - profileURL: UserProfile['profileURL']; + userId: number; + url: string; }; -export const updatePublicProfile = async ({ id, profileURL }: UpdatePublicProfileOptions) => { - const user = await getUserById({ id }); - // Existence check - await prisma.userProfile.findFirstOrThrow({ +export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOptions) => { + const isUrlTaken = await prisma.user.findFirst({ + select: { + id: true, + }, where: { - profileURL: user.profileURL ?? undefined, + id: { + not: userId, + }, + url, }, }); - return await prisma.$transaction(async (tx) => { - await tx.userProfile.create({ - data: { - profileURL, + if (isUrlTaken) { + throw new AppError( + AppErrorCode.PROFILE_URL_TAKEN, + 'Profile URL is taken', + 'The profile URL is already taken', + ); + } + + return await prisma.user.update({ + where: { + id: userId, + }, + data: { + url, + userProfile: { + upsert: { + create: { + bio: '', + }, + update: { + bio: '', + }, + }, }, - }); - await tx.userProfile.update({ - where: { - profileURL: user.profileURL ?? undefined, - }, - data: { - profileURL: profileURL, - }, - }); + }, }); }; diff --git a/packages/prisma/migrations/20240227111633_rework_user_profiles/migration.sql b/packages/prisma/migrations/20240227111633_rework_user_profiles/migration.sql new file mode 100644 index 000000000..6bf9c0759 --- /dev/null +++ b/packages/prisma/migrations/20240227111633_rework_user_profiles/migration.sql @@ -0,0 +1,37 @@ +/* + Warnings: + + - You are about to drop the column `profileURL` on the `User` table. All the data in the column will be lost. + - The primary key for the `UserProfile` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `profileBio` on the `UserProfile` table. All the data in the column will be lost. + - You are about to drop the column `profileURL` on the `UserProfile` table. All the data in the column will be lost. + - A unique constraint covering the columns `[url]` on the table `User` will be added. If there are existing duplicate values, this will fail. + - Added the required column `id` to the `UserProfile` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "User" DROP CONSTRAINT "User_profileURL_fkey"; + +-- DropIndex +DROP INDEX "User_profileURL_key"; + +-- DropIndex +DROP INDEX "UserProfile_profileURL_key"; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "profileURL", +ADD COLUMN "url" TEXT; + +-- AlterTable +ALTER TABLE "UserProfile" DROP CONSTRAINT "UserProfile_pkey", +DROP COLUMN "profileBio", +DROP COLUMN "profileURL", +ADD COLUMN "bio" TEXT, +ADD COLUMN "id" INTEGER NOT NULL, +ADD CONSTRAINT "UserProfile_pkey" PRIMARY KEY ("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_url_key" ON "User"("url"); + +-- AddForeignKey +ALTER TABLE "UserProfile" ADD CONSTRAINT "UserProfile_id_fkey" FOREIGN KEY ("id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 1e250821c..75dd9d1a5 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -43,9 +43,9 @@ model User { twoFactorSecret String? twoFactorEnabled Boolean @default(false) twoFactorBackupCodes String? - profileURL String? @unique + url String? @unique - UserProfile UserProfile? @relation(fields: [profileURL], references: [profileURL], onDelete: Cascade) + userProfile UserProfile? VerificationToken VerificationToken[] ApiToken ApiToken[] Template Template[] @@ -56,10 +56,10 @@ model User { } model UserProfile { - profileURL String @id @unique - profileBio String? + id Int @id + bio String? - User User? + User User? @relation(fields: [id], references: [id], onDelete: Cascade) } enum UserSecurityAuditLogType { diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 9730a97ec..2b83caa84 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs'; import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; @@ -80,14 +81,20 @@ export const profileRouter = router({ .input(ZUpdatePublicProfileMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { profileURL } = input; + const { url } = input; - return await updatePublicProfile({ - id: ctx.user.id, - profileURL, + const user = await updatePublicProfile({ + userId: ctx.user.id, + url, }); + + return { success: true, url: user.url }; } catch (err) { - console.error(err); + const error = AppError.parseError(err); + + if (error.code !== AppErrorCode.UNKNOWN_ERROR) { + throw AppError.parseErrorToTRPCError(error); + } throw new TRPCError({ code: 'BAD_REQUEST', diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index 1d139d20d..ecee47f34 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -17,7 +17,7 @@ export const ZUpdateProfileMutationSchema = z.object({ }); export const ZUpdatePublicProfileMutationSchema = z.object({ - profileURL: z.string().min(1), + url: z.string().min(1), }); export const ZUpdatePasswordMutationSchema = z.object({ diff --git a/packages/ui/icons/verified.tsx b/packages/ui/icons/verified.tsx new file mode 100644 index 000000000..5984e603d --- /dev/null +++ b/packages/ui/icons/verified.tsx @@ -0,0 +1,31 @@ +import { forwardRef } from 'react'; + +import type { LucideIcon } from 'lucide-react/dist/lucide-react'; + +export const VerifiedIcon: LucideIcon = forwardRef( + ({ size = 24, color = 'currentColor', ...props }, ref) => { + return ( + + + + + + ); + }, +); + +VerifiedIcon.displayName = 'VerifiedIcon'; diff --git a/packages/ui/primitives/input.tsx b/packages/ui/primitives/input.tsx index 1a5fba1bb..71b3cb521 100644 --- a/packages/ui/primitives/input.tsx +++ b/packages/ui/primitives/input.tsx @@ -10,7 +10,7 @@ const Input = React.forwardRef(