feat: the rest of the owl
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 it’s
|
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 it’s 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">
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 ">
|
||||||
What’s your email?
|
What’s 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
80
apps/web/src/app/(unauthenticated)/signup/view.tsx
Normal file
80
apps/web/src/app/(unauthenticated)/signup/view.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 I’m 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
463
apps/web/src/components/forms/v2/signup.tsx
Normal file
463
apps/web/src/components/forms/v2/signup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
87
apps/web/src/components/ui/user-profile-timur.tsx
Normal file
87
apps/web/src/components/ui/user-profile-timur.tsx
Normal 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 I’m 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 |
BIN
packages/assets/images/timur.png
Normal file
BIN
packages/assets/images/timur.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.';
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user