feat: the rest of the owl

This commit is contained in:
Mythie
2024-02-29 13:22:21 +11:00
parent e3e2cfbcfd
commit ecc9dc63ea
36 changed files with 828 additions and 528 deletions

View File

@@ -5,9 +5,10 @@ import React, { useEffect, useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import backgroundPattern from '@documenso/assets/images/background-lw-2.png'; import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { AnnouncementBar } from '@documenso/ui/primitives/announcement-bar';
import { Footer } from '~/components/(marketing)/footer'; import { Footer } from '~/components/(marketing)/footer';
import { Header } from '~/components/(marketing)/header'; import { Header } from '~/components/(marketing)/header';
@@ -20,6 +21,10 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
const [scrollY, setScrollY] = useState(0); const [scrollY, setScrollY] = useState(0);
const pathname = usePathname(); const pathname = usePathname();
const { getFlag } = useFeatureFlags();
const showProfilesAnnouncementBar = getFlag('marketing_profiles_announcement_bar');
useEffect(() => { useEffect(() => {
const onScroll = () => { const onScroll = () => {
setScrollY(window.scrollY); setScrollY(window.scrollY);
@@ -41,14 +46,31 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
'bg-background/50 backdrop-blur-md': scrollY > 5, 'bg-background/50 backdrop-blur-md': scrollY > 5,
})} })}
> >
<div className="absolute -inset-0 -z-[1] opacity-100"> {showProfilesAnnouncementBar && (
<Image <div className="relative inline-flex w-full items-center justify-center overflow-hidden px-4 py-2.5">
src={backgroundPattern} <div className="absolute inset-0 -z-[1]">
alt="background pattern" <Image
className="!h-34 w-full object-cover" src={launchWeekTwoImage}
/> className="h-full w-full object-cover"
</div> alt="Launch Week 2"
<AnnouncementBar className="relative" isShown={true} /> />
</div>
<div className="text-background text-center text-sm">
Claim your documenso public profile username now!{' '}
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
<a
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=marketing-announcement-bar`}
className="bg-background text-foreground rounded-md px-2.5 py-1 text-xs font-medium duration-300"
>
Claim Now
</a>
</div>
</div>
</div>
)}
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" /> <Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div> </div>

View File

@@ -191,7 +191,7 @@ export const SinglePlayerClient = () => {
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal"> <p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
Create a{' '} Create a{' '}
<Link <Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
target="_blank" target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors" className="hover:text-foreground/80 font-semibold transition-colors"
> >

View File

@@ -43,7 +43,7 @@ export default function NotFound() {
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center"> <div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button <Button
variant="secondary" variant="ghost"
className="w-32" className="w-32"
onClick={() => { onClick={() => {
void router.back(); void router.back();

View File

@@ -42,7 +42,7 @@ export const Callout = ({ starCount }: CalloutProps) => {
> >
Claim Community Plan Claim Community Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium"> <span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
-80% $30/mo
</span> </span>
</Button> </Button>

View File

@@ -9,6 +9,7 @@ import Link from 'next/link';
import LogoImage from '@documenso/assets/logo.png'; import LogoImage from '@documenso/assets/logo.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { HamburgerMenu } from './mobile-hamburger'; import { HamburgerMenu } from './mobile-hamburger';
import { MobileNavigation } from './mobile-navigation'; import { MobileNavigation } from './mobile-navigation';
@@ -68,21 +69,18 @@ export const Header = ({ className, ...props }: HeaderProps) => {
</Link> </Link>
<Link <Link
href="https://app.documenso.com/signin" href="https://app.documenso.com/signin?utm_source=marketing-header"
target="_blank" target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold" className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
> >
Sign in Sign in
</Link> </Link>
<Link
href="https://app.documenso.com/signin" <Button className="rounded-full" size="sm" asChild>
target="_blank" <Link href="https://app.documenso.com/signup?utm_source=marketing-header" target="_blank">
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
<span className="bg-primary dark:text-background rounded-full px-3 py-2 text-xs">
Sign up Sign up
</span> </Link>
</Link> </Button>
</div> </div>
<HamburgerMenu <HamburgerMenu

View File

@@ -116,7 +116,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
> >
Claim Community Plan Claim Community Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium"> <span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
-80% $30/mo
</span> </span>
</Button> </Button>
@@ -191,32 +191,41 @@ export const Hero = ({ className, ...props }: HeroProps) => {
<Widget className="mt-12"> <Widget className="mt-12">
<strong>Documenso Supporter Pledge</strong> <strong>Documenso Supporter Pledge</strong>
<p className="w-full max-w-[70ch]"> <p className="w-full max-w-[70ch]">
Our mission is to create an open signing infrastructure that empowers the world. We Our mission is to create an open signing infrastructure that empowers the world,
believe openness and cooperation are the way every business should be conducted. enabling businesses to embrace openness, cooperation, and transparency. We believe
that signing, as a fundamental act, should embody these values. By offering an
open-source signing solution, we aim to make document signing accessible, transparent,
and trustworthy.
</p> </p>
<p className="w-full max-w-[70ch]"> <p className="w-full max-w-[70ch]">
By creating an open source signing solution we want to bring these values to Through our platform, called Documenso, we strive to earn your trust by allowing
businesses' most fundamental act: signing. Document Signing should be open and self-hosting and providing complete visibility into its inner workings. We value
transparent, as should all trust based products. inclusivity and foster an environment where diverse perspectives and contributions are
welcomed, even though we may not implement them all.
</p> </p>
<p className="w-full max-w-[70ch]"> <p className="w-full max-w-[70ch]">
We aim to earn this trust by enabling everyone to self-host Documenso and inspect its At Documenso, we envision a web-enabled future for business and contracts, and we are
inner workings. We openly share our source, knowledge, and progress while creating committed to being the leading provider of open signing infrastructure. By combining
Documenso. exceptional product design with open-source principles, we aim to deliver a robust and
well-designed application that exceeds your expectations.
</p> </p>
<p className="w-full max-w-[70ch]"> <p className="w-full max-w-[70ch]">
Exceptional products are the results of exceptional communities and we strive to We understand that exceptional products are born from exceptional communities, and we
create an inclusive, creative environment, open to all who choose to support our invite you to join our open-source community. Your contributions, whether technical or
mission. We value the inputs, contributions, and perspectives of everyone in our non-technical, will help shape the future of signing. Together, we can create a better
community, even though we can't apply them all. future for everyone.
</p> </p>
<p className="w-full max-w-[70ch]"> <p className="w-full max-w-[70ch]">
We are building the next generation of trust software and community the way its meant Today we invite you to join us on this journey: By signing this mission statement you
to be: Beautifully designed and open for all to join. signal your support of Documenso's mission{' '}
<span className="bg-primary text-black">
(in a non-legally binding, but heartfelt way)
</span>{' '}
and lock in the community plan for forever, including everything we build this year.
</p> </p>
<div className="flex h-24 items-center"> <div className="flex h-24 items-center">

View File

@@ -47,11 +47,11 @@ export const MENU_NAVIGATION_LINKS = [
text: 'Privacy', text: 'Privacy',
}, },
{ {
href: 'https://app.documenso.com/signin', href: 'https://app.documenso.com/signin?utm_source=marketing-header',
text: 'Sign in', text: 'Sign in',
}, },
{ {
href: 'https://app.documenso.com/signup', href: 'https://app.documenso.com/signup?utm_source=marketing-header',
text: 'Sign up', text: 'Sign up',
}, },
]; ];
@@ -108,13 +108,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
onClick={() => handleMenuItemClick()} onClick={() => handleMenuItemClick()}
target={target} target={target}
> >
{href === 'https://app.documenso.com/signup' ? ( {text}
<span className="bg-primary dark:text-background rounded-full px-3 py-2 text-xl">
{text}
</span>
) : (
text
)}
</Link> </Link>
</motion.div> </motion.div>
))} ))}

View File

@@ -83,7 +83,11 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p> </p>
<Button className="rounded-full text-base" asChild> <Button className="rounded-full text-base" asChild>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank" className="mt-6"> <Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-free-plan`}
target="_blank"
className="mt-6"
>
Signup Now Signup Now
</Link> </Link>
</Button> </Button>
@@ -114,7 +118,10 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p> </p>
<Button className="mt-6 rounded-full text-base" asChild> <Button className="mt-6 rounded-full text-base" asChild>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank"> <Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-community`}
target="_blank"
>
Signup Now Signup Now
</Link> </Link>
</Button> </Button>

View File

@@ -86,7 +86,7 @@ export const SinglePlayerModeSuccess = ({
<p className="text-muted-foreground/60 mt-16 text-center text-sm"> <p className="text-muted-foreground/60 mt-16 text-center text-sm">
Create a{' '} Create a{' '}
<Link <Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
target="_blank" target="_blank"
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap" className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
> >

View File

@@ -208,7 +208,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<AnimatePresence> <AnimatePresence>
<motion.div key="email"> <motion.div key="email">
<label htmlFor="email" className="text-foreground text-lg font-semibold lg:text-xl"> <label htmlFor="email" className="text-foreground font-medium ">
Whats your email? Whats your email?
</label> </label>
@@ -265,11 +265,8 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
transform: 'translateX(25%)', transform: 'translateX(25%)',
}} }}
> >
<label <label htmlFor="name" className="text-foreground font-medium ">
htmlFor="name" And your name?
className="text-foreground text-lg font-semibold lg:text-xl"
>
and your name?
</label> </label>
<Controller <Controller

View File

@@ -29,7 +29,7 @@ export const ClaimProfileAlertDialog = ({ className, user }: ClaimProfileAlertDi
<div> <div>
<AlertTitle>Claim your profile</AlertTitle> <AlertTitle>Claim your profile</AlertTitle>
<AlertDescription className="mr-2"> <AlertDescription className="mr-2">
Profiles are coming soon! Claim your profile URL now to reserve your corner of the Profiles are coming soon! Claim your profile username now to reserve your corner of the
signing revolution. signing revolution.
</AlertDescription> </AlertDescription>
</div> </div>

View File

@@ -34,7 +34,7 @@ export default function ErrorPage({ error }: ErrorProps) {
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center"> <div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button <Button
variant="secondary" variant="ghost"
className="w-32" className="w-32"
onClick={() => { onClick={() => {
void router.back(); void router.back();

View File

@@ -19,7 +19,7 @@ export default function NotFound() {
</p> </p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center"> <div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button variant="secondary" asChild className="w-32"> <Button asChild className="w-32">
<Link href="/settings/teams"> <Link href="/settings/teams">
<ChevronLeft className="mr-2 h-4 w-4" /> <ChevronLeft className="mr-2 h-4 w-4" />
Go Back Go Back

View File

@@ -1,16 +1,12 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env'; 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 { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignUpForm } from '~/components/forms/signup'; import { SignUpFormV2 } from '~/components/forms/v2/signup';
import { UserProfileSkeleton } from '~/components/ui/user-profile-skeleton';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Sign Up', title: 'Sign Up',
@@ -37,57 +33,10 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
} }
return ( return (
<div className="flex w-screen max-w-screen-2xl justify-center gap-x-12 px-4 md:px-16"> <SignUpFormV2
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex"> className="w-screen max-w-screen-2xl px-4 md:px-16"
<div className="absolute -inset-8 -z-[2] backdrop-blur"> initialEmail={email || undefined}
<Image isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
src={communityCardsImage} />
fill={true}
alt="community-cards"
className="dark:brightness-95 dark:contrast-[70%] dark:invert"
/>
</div>
<div className="bg-background/50 absolute -inset-8 -z-[1] backdrop-blur-[2px]" />
<div className="relative flex h-full w-full flex-col items-center justify-evenly">
<div className="bg-background rounded-2xl border px-4 py-1 text-sm font-medium">
User profiles are coming soon!
</div>
<UserProfileSkeleton
user={{ name: 'Timur Ercan', email: 'timur@documenso.com', url: 'timur' }}
rows={2}
className="bg-background border-border w-full max-w-md rounded-2xl border shadow-md"
/>
<div />
</div>
</div>
<div className="border-border dark:bg-background z-10 max-w-lg rounded-xl border bg-neutral-100 p-6">
<h1 className="text-2xl font-semibold">Create a new account</h1>
<p className="text-muted-foreground mt-2 text-sm">
Create your account and start using state-of-the-art document signing. Open and beautiful
signing is within your grasp.
</p>
<hr className="-mx-6 my-4" />
<SignUpForm
className="mt-1"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED || true}
/>
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{' '}
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
Sign in instead
</Link>
</p>
</div>
</div>
); );
} }

View File

@@ -0,0 +1,80 @@
'use client';
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { SignUpFormV2 } from '~/components/forms/v2/signup';
import { UserProfileTimur } from '~/components/ui/user-profile-timur';
type SignUpPageViewProps = {
email?: string;
isGoogleSSOEnabled?: boolean;
};
export const SignUpPageView = ({ email, isGoogleSSOEnabled }: SignUpPageViewProps) => {
const [step, setStep] = useState(1);
return (
<div className="flex w-screen max-w-screen-2xl justify-center gap-x-12 px-4 md:px-16">
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
<div className="absolute -inset-8 -z-[2] backdrop-blur">
<Image
src={communityCardsImage}
fill={true}
alt="community-cards"
className="dark:brightness-95 dark:contrast-[70%] dark:invert"
/>
</div>
<div className="bg-background/50 absolute -inset-8 -z-[1] backdrop-blur-[2px]" />
<div className="relative flex h-full w-full flex-col items-center justify-evenly">
<div className="bg-background rounded-2xl border px-4 py-1 text-sm font-medium">
User profiles are coming soon!
</div>
{
<UserProfileTimur
rows={2}
className="bg-background border-border w-full max-w-md rounded-2xl border shadow-md"
/>
}
<div />
</div>
</div>
<div className="border-border dark:bg-background z-10 min-h-[min(800px,80vh)] w-full max-w-lg rounded-xl border bg-neutral-100 p-6">
<Stepper currentStep={step} onStepChanged={setStep} setCurrentStep={setStep}>
<>
<h1 className="text-2xl font-semibold">Create a new account</h1>
<p className="text-muted-foreground mt-2 text-sm">
Create your account and start using state-of-the-art document signing. Open and
beautiful signing is within your grasp.
</p>
<hr className="-mx-6 my-4" />
<SignUpFormV2
initialEmail={email || undefined}
isGoogleSSOEnabled={isGoogleSSOEnabled || true}
/>
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{' '}
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
Sign in instead
</Link>
</p>
</>
</Stepper>
</div>
</div>
);
};

View File

@@ -1,93 +0,0 @@
'use client';
import React from 'react';
import Image from 'next/image';
import { BadgeCheck, File } from 'lucide-react';
import Timur from '@documenso/assets/images/Timur.png';
import backgroundPattern from '@documenso/assets/images/background-blog-og.png';
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, CardFooter, CardHeader } from '@documenso/ui/primitives/card';
export default function ClaimUsernameCard() {
const onSignUpClick = () => {};
return (
<div className="relative">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center bg-contain opacity-50">
<Image
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[100%] dark:invert"
/>
</div>
<Card className={cn('relative px-16 py-16')}>
<Card className="flex flex-col items-center px-6 py-6">
<code className="bg-muted rounded-md px-1 py-1 text-sm">
<span>documenso.com/u/timur</span>
</code>
<Avatar className="dark:border-border mt-2 h-12 w-12 border-2 border-solid border-white">
<AvatarImage className="AvatarImage" src={Timur.src} alt="Timur" />
<AvatarFallback className="text-xs text-gray-400">Timur</AvatarFallback>
</Avatar>
<div className="flex flex-row gap-x-2">
Timur Ercan <BadgeCheck fill="#A2E771" />
</div>
<span className="text-center">
Hey Im Timur <br /> Pick any of the following agreements below and start signing to get
started
</span>
</Card>
<Card className="mt-2 items-center">
<CardHeader className="p-2">Documents</CardHeader>
<hr className="mb-2" />
<div className="mb-2 flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-x-2">
<File className="ml-3" />
<div className="flex flex-col">
<span className="text-md">NDA.pdf</span>
<span className="text-muted-foregroun mt-0.5 text-xs">
Like to discuss about my work?
</span>
</div>
</div>
<Button className="mr-3" variant="default">
Sign
</Button>
</div>
<hr className="mb-2" />
<div className="mb-2 flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-x-2">
<File className="ml-3" />
<div className="flex flex-col">
<span className="text-md">NDA.pdf</span>
<span className="text-muted-foregroun mt-0.5 text-xs">
Like to discuss about my work?
</span>
</div>
</div>
<Button className="mr-3" variant="default">
Sign
</Button>
</div>
</Card>
<CardFooter className="mt-20 justify-center">
<Button
type="button"
variant="outline"
className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
>
Claim Community Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
-80%
</span>
</Button>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -1,87 +0,0 @@
'use client';
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils';
import { NewHamburgerMenu } from './new-mobile-hamburger';
import { NewMobileNavigation } from './new-mobile-navigation';
export type HeaderProps = HTMLAttributes<HTMLElement>;
export const NewHeader = ({ className, ...props }: HeaderProps) => {
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
return (
<header className={cn('flex items-center justify-between', className)} {...props}>
<div className="flex items-center space-x-4">
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
<Image
src={LogoImage}
alt="Documenso Logo"
className="dark:invert"
width={170}
height={25}
/>
</Link>
</div>
<div className="hidden items-center gap-x-6 md:flex">
<Link
href="https://documenso.com/pricing"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
Pricing
</Link>
<Link
href="https://documenso.com/blog"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
Blog
</Link>
<Link
href="https://documenso.com/open"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
Open Startup
</Link>
<Link
href="/signin"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
Sign in
</Link>
<Link
href="/signup"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
<span className="bg-primary dark:text-background rounded-full px-3 py-2 text-xs">
Sign up
</span>
</Link>
</div>
<NewHamburgerMenu
onToggleMenuOpen={() => setIsHamburgerMenuOpen((v) => !v)}
isMenuOpen={isHamburgerMenuOpen}
/>
<NewMobileNavigation
isMenuOpen={isHamburgerMenuOpen}
onMenuOpenChange={setIsHamburgerMenuOpen}
/>
</header>
);
};

View File

@@ -1,20 +0,0 @@
'use client';
import { Menu, X } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export interface HamburgerMenuProps {
isMenuOpen: boolean;
onToggleMenuOpen?: () => void;
}
export const NewHamburgerMenu = ({ isMenuOpen, onToggleMenuOpen }: HamburgerMenuProps) => {
return (
<div className="flex md:hidden">
<Button variant="outline" className="z-20 w-10 p-0" onClick={onToggleMenuOpen}>
{isMenuOpen ? <X /> : <Menu />}
</Button>
</div>
);
};

View File

@@ -1,151 +0,0 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { motion, useReducedMotion } from 'framer-motion';
import { FaXTwitter } from 'react-icons/fa6';
import { LiaDiscord } from 'react-icons/lia';
import { LuGithub } from 'react-icons/lu';
import LogoImage from '@documenso/assets/logo.png';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
export type MobileNavigationProps = {
isMenuOpen: boolean;
onMenuOpenChange?: (_value: boolean) => void;
};
export const MENU_NAVIGATION_LINKS = [
{
href: 'https://documenso.com/singleplayer',
text: 'Singleplayer',
},
{
href: 'https://documenso.com/blog',
text: 'Blog',
},
{
href: 'https://documenso.com/pricing',
text: 'Pricing',
},
{
href: 'https://documenso.com/open',
text: 'Open Startup',
},
{
href: 'https://status.documenso.com',
text: 'Status',
},
{
href: 'mailto:support@documenso.com',
text: 'Support',
target: '_blank',
},
{
href: 'https://documenso.com/privacy',
text: 'Privacy',
},
{
href: '/signin',
text: 'Sign in',
},
{
href: '/signup',
text: 'Sign up',
},
];
export const NewMobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
const shouldReduceMotion = useReducedMotion();
const handleMenuItemClick = () => {
onMenuOpenChange?.(false);
};
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<SheetContent className="w-full max-w-[400px]">
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
<Image
src={LogoImage}
alt="Documenso Logo"
className="dark:invert"
width={170}
height={25}
/>
</Link>
<motion.div
className="mt-12 flex w-full flex-col items-start gap-y-4"
initial="initial"
animate="animate"
transition={{
staggerChildren: 0.03,
}}
>
{MENU_NAVIGATION_LINKS.map(({ href, text, target }) => (
<motion.div
key={href}
variants={{
initial: {
opacity: 0,
x: shouldReduceMotion ? 0 : 100,
},
animate: {
opacity: 1,
x: 0,
transition: {
duration: 0.5,
ease: 'backInOut',
},
},
}}
>
<Link
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
href={href}
onClick={() => handleMenuItemClick()}
target={target}
>
{href === 'https://app.documenso.com/signup' ? (
<span className="bg-primary dark:text-background rounded-full px-3 py-2 text-xl">
{text}
</span>
) : (
text
)}
</Link>
</motion.div>
))}
</motion.div>
<div className="mx-auto mt-8 flex w-full flex-wrap items-center gap-x-4 gap-y-4 ">
<Link
href="https://twitter.com/documenso"
target="_blank"
className="text-foreground hover:text-foreground/80"
>
<FaXTwitter className="h-6 w-6" />
</Link>
<Link
href="https://github.com/documenso/documenso"
target="_blank"
className="text-foreground hover:text-foreground/80"
>
<LuGithub className="h-6 w-6" />
</Link>
<Link
href="https://documen.so/discord"
target="_blank"
className="text-foreground hover:text-foreground/80"
>
<LiaDiscord className="h-7 w-7" />
</Link>
</div>
</SheetContent>
</Sheet>
);
};

View File

@@ -9,6 +9,7 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import profileClaimTeaserImage from '@documenso/assets/images/profile-claim-teaser.png'; import profileClaimTeaserImage from '@documenso/assets/images/profile-claim-teaser.png';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@@ -35,7 +36,14 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { UserProfileSkeleton } from '../ui/user-profile-skeleton'; import { UserProfileSkeleton } from '../ui/user-profile-skeleton';
export const ZClaimPublicProfileFormSchema = z.object({ export const ZClaimPublicProfileFormSchema = z.object({
url: z.string().trim().min(1, { message: 'Please enter a valid URL slug.' }), url: z
.string()
.trim()
.toLowerCase()
.min(1, { message: 'Please enter a valid username.' })
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
}),
}); });
export type TClaimPublicProfileFormSchema = z.infer<typeof ZClaimPublicProfileFormSchema>; export type TClaimPublicProfileFormSchema = z.infer<typeof ZClaimPublicProfileFormSchema>;
@@ -57,6 +65,8 @@ export const ClaimPublicProfileDialogForm = ({
const [claimed, setClaimed] = useState(false); const [claimed, setClaimed] = useState(false);
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
const form = useForm<TClaimPublicProfileFormSchema>({ const form = useForm<TClaimPublicProfileFormSchema>({
values: { values: {
url: user.url || '', url: user.url || '',
@@ -82,12 +92,17 @@ export const ClaimPublicProfileDialogForm = ({
if (error.code === AppErrorCode.PROFILE_URL_TAKEN) { if (error.code === AppErrorCode.PROFILE_URL_TAKEN) {
form.setError('url', { form.setError('url', {
type: 'manual', type: 'manual',
message: 'This URL is already taken', message: 'This username is already taken',
});
} else if (error.code === AppErrorCode.PREMIUM_PROFILE_URL) {
form.setError('url', {
type: 'manual',
message: error.message,
}); });
} else if (error.code !== AppErrorCode.UNKNOWN_ERROR) { } else if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
toast({ toast({
title: 'An error occurred', title: 'An error occurred',
description: err.message, description: error.userMessage ?? error.message,
variant: 'destructive', variant: 'destructive',
}); });
} else { } else {
@@ -131,7 +146,7 @@ export const ClaimPublicProfileDialogForm = ({
name="url" name="url"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Public profile URL</FormLabel> <FormLabel>Public profile username</FormLabel>
<FormControl> <FormControl>
<Input type="text" className="mb-2 mt-2" {...field} /> <Input type="text" className="mb-2 mt-2" {...field} />
@@ -140,7 +155,7 @@ export const ClaimPublicProfileDialogForm = ({
<FormMessage /> <FormMessage />
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block truncate rounded-md px-2 py-1 text-sm"> <div className="bg-muted/50 text-muted-foreground mt-2 inline-block truncate rounded-md px-2 py-1 text-sm">
documenso.com/u/{field.value || '<username>'} {baseUrl.host}/u/{field.value || '<username>'}
</div> </div>
</FormItem> </FormItem>
)} )}

View File

@@ -61,7 +61,7 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const src = searchParams?.get('src') ?? null; const utmSrc = searchParams?.get('utm_source') ?? null;
const form = useForm<TSignUpFormSchema>({ const form = useForm<TSignUpFormSchema>({
values: { values: {
@@ -93,7 +93,7 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
analytics.capture('App: User Sign Up', { analytics.capture('App: User Sign Up', {
email, email,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
custom_campaign_params: { src }, custom_campaign_params: { src: utmSrc },
}); });
} catch (err) { } catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {

View File

@@ -0,0 +1,463 @@
'use client';
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { FcGoogle } from 'react-icons/fc';
import { z } from 'zod';
import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-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 { PasswordInput } from '@documenso/ui/primitives/password-input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { UserProfileSkeleton } from '~/components/ui/user-profile-skeleton';
import { UserProfileTimur } from '~/components/ui/user-profile-timur';
const SIGN_UP_REDIRECT_PATH = '/documents';
type SignUpStep = 'BASIC_DETAILS' | 'CLAIM_USERNAME';
export const ZSignUpFormV2Schema = z
.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().email().min(1),
password: ZPasswordSchema,
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
url: z
.string()
.trim()
.toLowerCase()
.min(1, { message: 'We need a username to create your profile' })
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
}),
})
.refine(
(data) => {
const { name, email, password } = data;
return !password.includes(name) && !password.includes(email.split('@')[0]);
},
{
message: 'Password should not be common or based on personal information',
},
);
export type TSignUpFormV2Schema = z.infer<typeof ZSignUpFormV2Schema>;
export type SignUpFormV2Props = {
className?: string;
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
};
export const SignUpFormV2 = ({
className,
initialEmail,
isGoogleSSOEnabled,
}: SignUpFormV2Props) => {
const { toast } = useToast();
const analytics = useAnalytics();
const router = useRouter();
const searchParams = useSearchParams();
const [step, setStep] = useState<SignUpStep>('BASIC_DETAILS');
const utmSrc = searchParams?.get('utm_source') ?? null;
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
const form = useForm<TSignUpFormV2Schema>({
values: {
name: '',
email: initialEmail ?? '',
password: '',
signature: '',
url: '',
},
mode: 'onBlur',
resolver: zodResolver(ZSignUpFormV2Schema),
});
const isSubmitting = form.formState.isSubmitting;
const name = form.watch('name');
const url = form.watch('url');
// To continue we need to make sure name, email, password and signature are valid
const canContinue =
form.formState.dirtyFields.name &&
form.formState.errors.name === undefined &&
form.formState.dirtyFields.email &&
form.formState.errors.email === undefined &&
form.formState.dirtyFields.password &&
form.formState.errors.password === undefined &&
form.formState.dirtyFields.signature &&
form.formState.errors.signature === undefined;
console.log({ formSTate: form.formState });
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormV2Schema) => {
try {
await signup({ name, email, password, signature, url });
router.push(`/unverified-account`);
toast({
title: 'Registration Successful',
description:
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
duration: 5000,
});
analytics.capture('App: User Sign Up', {
email,
timestamp: new Date().toISOString(),
custom_campaign_params: { src: utmSrc },
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.PROFILE_URL_TAKEN) {
form.setError('url', {
type: 'manual',
message: 'This username has already been taken',
});
} else if (error.code === AppErrorCode.PREMIUM_PROFILE_URL) {
form.setError('url', {
type: 'manual',
message: error.message,
});
} else 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',
description:
'We encountered an unknown error while attempting to sign you up. Please try again later.',
variant: 'destructive',
});
}
}
};
const onSignUpWithGoogleClick = async () => {
try {
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
} catch (err) {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you Up. Please try again later.',
variant: 'destructive',
});
}
};
return (
<div className={cn('flex justify-center gap-x-12', className)}>
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
<div className="absolute -inset-8 -z-[2] backdrop-blur">
<Image
src={communityCardsImage}
fill={true}
alt="community-cards"
className="dark:brightness-95 dark:contrast-[70%] dark:invert"
/>
</div>
<div className="bg-background/50 absolute -inset-8 -z-[1] backdrop-blur-[2px]" />
<div className="relative flex h-full w-full flex-col items-center justify-evenly">
<div className="bg-background rounded-2xl border px-4 py-1 text-sm font-medium">
User profiles are coming soon!
</div>
<AnimatePresence>
{step === 'BASIC_DETAILS' ? (
<motion.div className="w-full max-w-md" layoutId="user-profile">
<UserProfileTimur
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
/>
</motion.div>
) : (
<motion.div className="w-full max-w-md" layoutId="user-profile">
<UserProfileSkeleton
user={{ name, url }}
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
/>
</motion.div>
)}
</AnimatePresence>
<div />
</div>
</div>
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(800px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
{step === 'BASIC_DETAILS' && (
<div className="h-20">
<h1 className="text-2xl font-semibold">Create a new account</h1>
<p className="text-muted-foreground mt-2 text-sm">
Create your account and start using state-of-the-art document signing. Open and
beautiful signing is within your grasp.
</p>
</div>
)}
{step === 'CLAIM_USERNAME' && (
<div className="h-20">
<h1 className="text-2xl font-semibold">Claim your username now</h1>
<p className="text-muted-foreground mt-2 text-sm">
You will get notified & be able to set up your documenso public profile when we launch
the feature.
</p>
</div>
)}
<hr className="-mx-6 my-4" />
<Form {...form}>
<form
className="flex w-full flex-1 flex-col gap-y-4"
onSubmit={form.handleSubmit(onFormSubmit)}
>
{step === 'BASIC_DETAILS' && (
<fieldset
className={cn(
'flex h-[500px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[600px]',
)}
disabled={isSubmitting}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange } }) => (
<FormItem>
<FormLabel>Sign Here</FormLabel>
<FormControl>
<SignaturePad
className="h-36 w-full"
disabled={isSubmitting}
containerClassName="mt-2 rounded-lg border bg-background"
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isGoogleSSOEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or</span>
<div className="bg-border h-px flex-1" />
</div>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Sign Up with Google
</Button>
</>
)}
<p className="text-muted-foreground mt-4 text-sm">
Already have an account?{' '}
<Link href="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
Sign in instead
</Link>
</p>
</fieldset>
)}
{step === 'CLAIM_USERNAME' && (
<fieldset
className={cn(
'flex h-[500px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[600px]',
)}
disabled={isSubmitting}
>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile username</FormLabel>
<FormControl>
<Input type="text" className="mb-2 mt-2 lowercase" {...field} />
</FormControl>
<FormMessage />
<div className="bg-muted/50 border-border text-muted-foreground mt-2 inline-block truncate rounded-md border px-2 py-1 text-sm lowercase">
{baseUrl.host}/u/{field.value || '<username>'}
</div>
</FormItem>
)}
/>
</fieldset>
)}
<div className="mt-6">
{step === 'BASIC_DETAILS' && (
<p className="text-muted-foreground text-sm">
<span className="font-medium">Basic details</span> 1/2
</p>
)}
{step === 'CLAIM_USERNAME' && (
<p className="text-muted-foreground text-sm">
<span className="font-medium">Claim username</span> 2/2
</p>
)}
<div className="bg-foreground/40 relative mt-4 h-1.5 rounded-full">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="bg-documenso absolute inset-y-0 left-0 rounded-full"
style={{
width: step === 'BASIC_DETAILS' ? '50%' : '100%',
}}
/>
</div>
</div>
<div className="flex items-center gap-x-4">
{/* Go back button, disabled if step is basic details */}
<Button
type="button"
size="lg"
variant="secondary"
className="flex-1"
disabled={step === 'BASIC_DETAILS'}
loading={form.formState.isSubmitting}
onClick={() => setStep('BASIC_DETAILS')}
>
Back
</Button>
{/* Continue button */}
{step === 'BASIC_DETAILS' && (
<Button
type="button"
size="lg"
className="flex-1 disabled:cursor-not-allowed"
disabled={!canContinue}
loading={form.formState.isSubmitting}
onClick={() => setStep('CLAIM_USERNAME')}
>
Next
</Button>
)}
{/* Sign up button */}
{step === 'CLAIM_USERNAME' && (
<Button
loading={form.formState.isSubmitting}
disabled={!form.formState.isValid}
type="submit"
size="lg"
className="flex-1"
>
Complete
</Button>
)}
</div>
</form>
</Form>
</div>
</div>
);
};

View File

@@ -46,7 +46,7 @@ export default function NotFoundPartial({ children }: NotFoundPartialProps) {
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center"> <div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button <Button
variant="secondary" variant="ghost"
className="w-32" className="w-32"
onClick={() => { onClick={() => {
void router.back(); void router.back();

View File

@@ -1,18 +1,16 @@
'use client'; 'use client';
import { File } from 'lucide-react'; import { File, User2 } from 'lucide-react';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; 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 type { User } from '@documenso/prisma/client';
import { VerifiedIcon } from '@documenso/ui/icons/verified'; import { VerifiedIcon } from '@documenso/ui/icons/verified';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
export type UserProfileSkeletonProps = { export type UserProfileSkeletonProps = {
className?: string; className?: string;
user: Pick<User, 'name' | 'url' | 'email'>; user: Pick<User, 'name' | 'url'>;
rows?: number; rows?: number;
}; };
@@ -26,28 +24,27 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
className, className,
)} )}
> >
<div className="border-border bg-background text-muted-foreground inline-block rounded-md border px-2.5 py-1.5 text-sm"> <div className="border-border bg-background text-muted-foreground inline-flex items-center rounded-md border px-2.5 py-1.5 text-sm">
{baseUrl.host}/u/{user.url} <span>{baseUrl.host}/u/</span>
<span className="inline-block max-w-[8rem] truncate lowercase">{user.url}</span>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<div className="bg-primary/40 rounded-full p-2"> <div className="bg-primary/10 rounded-full p-1.5">
<Avatar className="h-20 w-20"> <div className="bg-background flex h-20 w-20 items-center justify-center rounded-full border-2">
<AvatarFallback className="bg-primary/80 text-documenso-900 text-xl tracking-wider"> <User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
{user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase()} </div>
</AvatarFallback>
</Avatar>
</div> </div>
</div> </div>
<div className="mt-6"> <div className="mt-6">
<div className="flex items-center gap-x-2"> <div className="flex items-center justify-center gap-x-2">
<h2 className="text-2xl font-semibold">{user.name}</h2> <h2 className="max-w-[12rem] truncate text-2xl font-semibold">{user.name}</h2>
<VerifiedIcon className="text-primary h-8 w-8" /> <VerifiedIcon className="text-primary h-8 w-8" />
</div> </div>
<div className="dark:bg-foreground/20 mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-200" /> <div className="dark:bg-foreground/30 mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300" />
<div className="dark:bg-foreground/20 mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200" /> <div className="dark:bg-foreground/20 mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200" />
</div> </div>
@@ -68,7 +65,7 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} /> <File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
<div className="space-y-2"> <div className="space-y-2">
<div className="dark:bg-foreground/20 h-1.5 w-24 rounded-full bg-neutral-200 md:w-36" /> <div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" /> <div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,87 @@
'use client';
import Image from 'next/image';
import { File } from 'lucide-react';
import timurImage from '@documenso/assets/images/timur.png';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { VerifiedIcon } from '@documenso/ui/icons/verified';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type UserProfileTimurProps = {
className?: string;
rows?: number;
};
export const UserProfileTimur = ({ className, rows = 2 }: UserProfileTimurProps) => {
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
return (
<div
className={cn(
'dark:bg-background flex flex-col items-center rounded-xl bg-neutral-100 p-4',
className,
)}
>
<div className="border-border bg-background text-muted-foreground inline-block rounded-md border px-2.5 py-1.5 text-sm">
{baseUrl.host}/u/timur
</div>
<div className="mt-4">
<Image
src={timurImage}
className="h-20 w-20 rounded-full"
alt="image of timur ercan founder of documenso"
/>
</div>
<div className="mt-6">
<div className="flex items-center justify-center gap-x-2">
<h2 className="text-2xl font-semibold">Timur Ercan</h2>
<VerifiedIcon className="text-primary h-8 w-8" />
</div>
<p className="text-muted-foreground mt-4 max-w-[40ch] text-center text-sm">Hey Im Timur</p>
<p className="text-muted-foreground mt-1 max-w-[40ch] text-center text-sm">
Pick any of the following agreements below and start signing to get started
</p>
</div>
<div className="mt-8 w-full">
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
Documents
</div>
{Array(rows)
.fill(0)
.map((_, index) => (
<div
key={index}
className="bg-background flex items-center justify-between gap-x-6 p-4"
>
<div className="flex items-center gap-x-2">
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
<div className="space-y-2">
<div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
</div>
</div>
<div className="flex-shrink-0">
<Button type="button" size="sm" className="pointer-events-none w-32">
Sign
</Button>
</div>
</div>
))}
</div>
</div>
</div>
);
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -25,6 +25,7 @@ export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_teams: true, app_teams: true,
app_document_page_view_history_sheet: false, app_document_page_view_history_sheet: false,
marketing_header_single_player_mode: false, marketing_header_single_player_mode: false,
marketing_profiles_announcement_bar: true,
} as const; } as const;
/** /**

View File

@@ -19,6 +19,7 @@ export enum AppErrorCode {
'SCHEMA_FAILED' = 'SchemaFailed', 'SCHEMA_FAILED' = 'SchemaFailed',
'TOO_MANY_REQUESTS' = 'TooManyRequests', 'TOO_MANY_REQUESTS' = 'TooManyRequests',
'PROFILE_URL_TAKEN' = 'ProfileUrlTaken', 'PROFILE_URL_TAKEN' = 'ProfileUrlTaken',
'PREMIUM_PROFILE_URL' = 'PremiumProfileUrl',
} }
const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = { const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
@@ -34,6 +35,7 @@ const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
[AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR', [AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS', [AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS',
[AppErrorCode.PROFILE_URL_TAKEN]: 'BAD_REQUEST', [AppErrorCode.PROFILE_URL_TAKEN]: 'BAD_REQUEST',
[AppErrorCode.PREMIUM_PROFILE_URL]: 'BAD_REQUEST',
}; };
export const ZAppErrorJsonSchema = z.object({ export const ZAppErrorJsonSchema = z.object({

View File

@@ -7,15 +7,17 @@ import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/pri
import { IS_BILLING_ENABLED } from '../../constants/app'; import { IS_BILLING_ENABLED } from '../../constants/app';
import { SALT_ROUNDS } from '../../constants/auth'; import { SALT_ROUNDS } from '../../constants/auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface CreateUserOptions { export interface CreateUserOptions {
name: string; name: string;
email: string; email: string;
password: string; password: string;
signature?: string | null; signature?: string | null;
url?: string;
} }
export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => { export const createUser = async ({ name, email, password, signature, url }: CreateUserOptions) => {
const hashedPassword = await hash(password, SALT_ROUNDS); const hashedPassword = await hash(password, SALT_ROUNDS);
const userExists = await prisma.user.findFirst({ const userExists = await prisma.user.findFirst({
@@ -28,6 +30,22 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
throw new Error('User already exists'); throw new Error('User already exists');
} }
if (url) {
const urlExists = await prisma.user.findFirst({
where: {
url,
},
});
if (urlExists) {
throw new AppError(
AppErrorCode.PROFILE_URL_TAKEN,
'Profile username is taken',
'The profile username is already taken',
);
}
}
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
name, name,
@@ -35,6 +53,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
password: hashedPassword, password: hashedPassword,
signature, signature,
identityProvider: IdentityProvider.DOCUMENSO, identityProvider: IdentityProvider.DOCUMENSO,
url,
}, },
}); });

View File

@@ -23,8 +23,8 @@ export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOp
if (isUrlTaken) { if (isUrlTaken) {
throw new AppError( throw new AppError(
AppErrorCode.PROFILE_URL_TAKEN, AppErrorCode.PROFILE_URL_TAKEN,
'Profile URL is taken', 'Profile username is taken',
'The profile URL is already taken', 'The profile username is already taken',
); );
} }

View File

@@ -1,6 +1,8 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { env } from 'next-runtime-env'; import { env } from 'next-runtime-env';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { compareSync } from '@documenso/lib/server-only/auth/hash'; import { compareSync } from '@documenso/lib/server-only/auth/hash';
import { createUser } from '@documenso/lib/server-only/user/create-user'; import { createUser } from '@documenso/lib/server-only/user/create-user';
@@ -21,14 +23,29 @@ export const authRouter = router({
}); });
} }
const { name, email, password, signature } = input; const { name, email, password, signature, url } = input;
const user = await createUser({ name, email, password, signature }); if ((true || IS_BILLING_ENABLED()) && url && url.length <= 6) {
throw new AppError(
AppErrorCode.PREMIUM_PROFILE_URL,
'Only subscribers can have a username shorter than 6 characters',
);
}
const user = await createUser({ name, email, password, signature, url });
await sendConfirmationToken({ email: user.email }); await sendConfirmationToken({ email: user.email });
return user; return user;
} catch (err) { } catch (err) {
console.log(err);
const error = AppError.parseError(err);
if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
throw AppError.parseErrorToTRPCError(error);
}
let message = let message =
'We were unable to create your account. Please review the information you provided and try again.'; 'We were unable to create your account. Please review the information you provided and try again.';

View File

@@ -21,6 +21,7 @@ export const ZSignUpMutationSchema = z.object({
email: z.string().email(), email: z.string().email(),
password: ZPasswordSchema, password: ZPasswordSchema,
signature: z.string().min(1, { message: 'A signature is required.' }), signature: z.string().min(1, { message: 'A signature is required.' }),
url: z.string().optional(),
}); });
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>; export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;

View File

@@ -1,6 +1,8 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs'; import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
@@ -11,6 +13,7 @@ import { updatePassword } from '@documenso/lib/server-only/user/update-password'
import { updateProfile } from '@documenso/lib/server-only/user/update-profile'; import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
import { updatePublicProfile } from '@documenso/lib/server-only/user/update-public-profile'; import { updatePublicProfile } from '@documenso/lib/server-only/user/update-public-profile';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc'; import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
import { import {
@@ -83,6 +86,21 @@ export const profileRouter = router({
try { try {
const { url } = input; const { url } = input;
if (IS_BILLING_ENABLED() && url.length <= 6) {
const subscriptions = await getSubscriptionsByUserId({
userId: ctx.user.id,
}).then((subscriptions) =>
subscriptions.filter((s) => s.status === SubscriptionStatus.ACTIVE),
);
if (subscriptions.length === 0) {
throw new AppError(
AppErrorCode.PREMIUM_PROFILE_URL,
'Only subscribers can have a username shorter than 6 characters',
);
}
}
const user = await updatePublicProfile({ const user = await updatePublicProfile({
userId: ctx.user.id, userId: ctx.user.id,
url, url,

View File

@@ -17,7 +17,14 @@ export const ZUpdateProfileMutationSchema = z.object({
}); });
export const ZUpdatePublicProfileMutationSchema = z.object({ export const ZUpdatePublicProfileMutationSchema = z.object({
url: z.string().min(1), url: z
.string()
.trim()
.toLowerCase()
.min(1, { message: 'Please enter a valid username.' })
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
}),
}); });
export const ZUpdatePasswordMutationSchema = z.object({ export const ZUpdatePasswordMutationSchema = z.object({

View File

@@ -1,32 +0,0 @@
import Link from 'next/link';
import { cn } from '../lib/utils';
interface AnnouncementBarProps {
isShown: boolean;
className: string;
}
export const AnnouncementBar: React.FC<AnnouncementBarProps> = ({ isShown, className }) => {
return (
isShown && (
<div
className={cn(
'flex h-full w-full items-center justify-center gap-4 border-b-2 p-1',
className,
)}
>
<div className="text-center">
<span className="text-sm text-white">Claim your documenso public profile URL now!</span>{' '}
<span className="text-sm font-medium text-white">documenso.com/u/yourname</span>
</div>
<div className="flex items-center justify-center gap-4 rounded-lg bg-white px-3 py-1">
<div className="text-xs text-gray-900">
<Link href="https://app.documenso.com">Claim now</Link>
</div>
</div>
</div>
)
);
};