wip: refresh design

This commit is contained in:
Mythie
2023-06-09 18:21:18 +10:00
parent 76b2fb5edd
commit 159bcade7b
432 changed files with 19640 additions and 29359 deletions

View File

@ -0,0 +1,41 @@
import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types';
export const claimPlan = async ({
name,
email,
planId,
signatureDataUrl,
signatureText,
}: TClaimPlanRequestSchema) => {
const response = await fetch('/api/claim-plan', {
method: 'POST',
body: JSON.stringify({
name,
email,
planId,
signatureDataUrl,
signatureText,
}),
headers: {
'Content-Type': 'application/json',
},
});
const body = await response.json();
if (response.status !== 200) {
throw new Error('Failed to claim plan');
}
const safeBody = ZClaimPlanResponseSchema.safeParse(body);
if (!safeBody.success) {
throw new Error('Failed to claim plan');
}
if ('error' in safeBody.data) {
throw new Error(safeBody.data.error);
}
return safeBody.data.redirectUrl;
};

View File

@ -0,0 +1,37 @@
import { z } from 'zod';
export const ZClaimPlanRequestSchema = z
.object({
email: z
.string()
.email()
.transform((value) => value.toLowerCase()),
name: z.string(),
planId: z.string(),
})
.and(
z.union([
z.object({
signatureDataUrl: z.string().min(1),
signatureText: z.null(),
}),
z.object({
signatureDataUrl: z.null(),
signatureText: z.string().min(1),
}),
]),
);
export type TClaimPlanRequestSchema = z.infer<typeof ZClaimPlanRequestSchema>;
export const ZClaimPlanResponseSchema = z
.object({
redirectUrl: z.string(),
})
.or(
z.object({
error: z.string(),
}),
);
export type TClaimPlanResponseSchema = z.infer<typeof ZClaimPlanResponseSchema>;

View File

@ -0,0 +1,173 @@
import { Caveat } from 'next/font/google';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ArrowRight } from 'lucide-react';
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { PasswordReveal } from '~/components/(marketing)/password-reveal';
const fontCaveat = Caveat({
weight: ['500'],
subsets: ['latin'],
display: 'swap',
});
export type ClaimedPlanPageProps = {
searchParams?: {
sessionId?: string;
};
};
export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlanPageProps) {
const { sessionId } = searchParams;
const session = await stripe.checkout.sessions.retrieve(sessionId as string);
const user = await prisma.user.findFirst({
where: {
id: Number(session.client_reference_id),
},
});
if (!user) {
redirect('/');
}
const signatureText = session.metadata?.signatureText || user.name;
let signatureDataUrl = '';
if (session.metadata?.signatureDataUrl) {
const result = await redis.get<string>(`signature:${session.metadata.signatureDataUrl}`);
if (result) {
signatureDataUrl = result;
}
}
const password = await redis.get<string>(`user:${user.id}:temp-password`);
return (
<div className="mt-12">
<h1 className="text-3xl font-bold text-slate-900 md:text-4xl">
Welcome to the <span className="text-primary">open signing</span> revolution{' '}
<u>{user.name}</u>
</h1>
<p className="mt-4 max-w-prose text-base text-slate-500 md:text-lg">
It's not every day you get to be part of a revolution.
</p>
<p className="mt-4 max-w-prose text-base text-slate-500 md:text-lg">
But today is that day, by signing up to Documenso, you're joining a movement of people who
want to make the world a better place.
</p>
<p className="mt-4 max-w-prose text-base text-slate-500 md:text-lg">
We're going to change the way people sign documents. We're going to make it easier, faster,
and more secure. And we're going to do it together.
</p>
<div className="mt-12">
<h2 className="text-2xl font-bold text-slate-900">Let's do it together</h2>
<div className="-mx-4 mt-8 flex md:-mx-8">
<div className="flex flex-1 flex-col justify-end gap-y-4 border-r px-4 last:border-r-0 md:px-8 lg:flex-none">
<p
className={cn(
'text-4xl font-semibold text-slate-900 md:text-5xl',
fontCaveat.className,
)}
>
Timur
</p>
<p className="text-sm text-slate-500 md:text-lg">
Timur Ercan
<span className="block lg:hidden" />
<span className="hidden lg:inline"> - </span>
Co Founder
</p>
</div>
<div className="flex flex-1 flex-col justify-end gap-y-4 border-r px-4 last:border-r-0 md:px-8 lg:flex-none">
<p
className={cn(
'text-4xl font-semibold text-slate-900 md:text-5xl',
fontCaveat.className,
)}
>
Lucas
</p>
<p className="text-sm text-slate-500 md:text-lg">
Lucas Smith
<span className="block lg:hidden" />
<span className="hidden lg:inline"> - </span>
Co Founder
</p>
</div>
<div className="flex flex-1 flex-col justify-end gap-y-4 border-r px-4 last:border-r-0 md:px-8 lg:flex-none">
{signatureDataUrl && (
<img src={signatureDataUrl} alt="your-signature" className="max-w-[172px]" />
)}
{!signatureDataUrl && (
<p
className={cn(
'text-4xl font-semibold text-slate-900 md:text-5xl',
fontCaveat.className,
)}
>
{signatureText}
</p>
)}
<p className="text-sm text-slate-500 md:text-lg">
{user.name}
<span className="block lg:hidden" />
<span className="hidden lg:inline"> - </span>
Our new favourite customer
</p>
</div>
</div>
</div>
<div className="mt-12">
<h2 className="text-2xl font-bold text-slate-900">Your sign in details</h2>
<div className="mt-4">
<p className="text-lg text-slate-500">
<span className="font-bold">Email:</span> {user.email}
</p>
<p className="mt-2 text-lg text-slate-500">
<span className="font-bold">Password:</span>{' '}
<PasswordReveal password={password ?? 'password'} />
</p>
</div>
<p className="mt-4 text-sm italic text-slate-500">
This is a temporary password. Please change it as soon as possible.
</p>
<Link
// eslint-disable-next-line turbo/no-undeclared-env-vars
href={`${process.env.NEXT_PUBLIC_APP_URL}/login`}
target="_blank"
className="mt-4 block"
>
<Button size="lg" className="text-base">
Let's get started!
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
import React from 'react';
import { Footer } from '~/components/(marketing)/footer';
import { Header } from '~/components/(marketing)/header';
export type MarketingLayoutProps = {
children: React.ReactNode;
};
export default function MarketingLayout({ children }: MarketingLayoutProps) {
return (
<div className="relative max-w-[100vw] overflow-y-auto overflow-x-hidden pt-20 md:pt-28">
<div className="fixed left-0 top-0 z-50 w-full bg-white/50 backdrop-blur-md">
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div>
<div className="relative mx-auto max-w-screen-xl px-4 lg:px-8">{children}</div>
<Footer className="mt-24 bg-transparent backdrop-blur-[2px]" />
</div>
);
}

View File

@ -0,0 +1,31 @@
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
import { Caveat } from 'next/font/google';
import { cn } from '@documenso/ui/lib/utils';
import { Callout } from '~/components/(marketing)/callout';
import { FasterSmarterBeautifulBento } from '~/components/(marketing)/faster-smarter-beautiful-bento';
import { Hero } from '~/components/(marketing)/hero';
import { OpenBuildTemplateBento } from '~/components/(marketing)/open-build-template-bento';
import { ShareConnectPaidWidgetBento } from '~/components/(marketing)/share-connect-paid-widget-bento';
const fontCaveat = Caveat({
weight: ['500'],
subsets: ['latin'],
display: 'swap',
variable: '--font-caveat',
});
export default async function IndexPage() {
return (
<div className={cn('mt-12', fontCaveat.variable)}>
<Hero />
<FasterSmarterBeautifulBento className="my-48" />
<ShareConnectPaidWidgetBento className="my-48" />
<OpenBuildTemplateBento className="my-48" />
<Callout />
</div>
);
}

View File

@ -0,0 +1,163 @@
import Link from 'next/link';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import { PricingTable } from '~/components/(marketing)/pricing-table';
export type PricingPageProps = {
searchParams?: {
planId?: string;
email?: string;
name?: string;
cancelled?: string;
};
};
export default function PricingPage() {
return (
<div className="mt-12">
<div className="text-center">
<h1 className="text-3xl font-bold lg:text-5xl">Pricing</h1>
<p className="mt-4 text-lg leading-normal text-[#31373D]">
Designed for every stage of your journey.
</p>
<p className="text-lg leading-normal text-[#31373D]">Get started today.</p>
</div>
<div className="mt-12">
<PricingTable />
</div>
<div className="mx-auto mt-36 max-w-2xl">
{/* FAQ Section */}
<h2 className="text-4xl font-semibold">FAQs</h2>
<Accordion type="multiple" className="mt-8">
<AccordionItem value="plan-differences">
<AccordionTrigger className="text-left text-lg font-semibold">
What is the difference between the plans?
</AccordionTrigger>
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
You can self-host Documenso for free or use our ready-to-use hosted version. The
hosted version comes with additional support, painless scalability and more. Early
adopters of the community plan will get access to all features we build this year, for
no additional cost! Forever! Yes, that includes multiple users per account later. If
you want Documenso for your enterprise, we are happy to talk about your needs.
</AccordionContent>
</AccordionItem>
<AccordionItem value="data-handling">
<AccordionTrigger className="text-left text-lg font-semibold">
How do you handle my data?
</AccordionTrigger>
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
Securely. Our data centers are located in Frankfurt (Germany), giving us the best
local privacy laws. We are very aware of the sensitive nature of our data and follow
best practices to ensure the security and integrity of the data entrusted to us.
</AccordionContent>
</AccordionItem>
<AccordionItem value="should-use-cloud">
<AccordionTrigger className="text-left text-lg font-semibold">
Why should I use your hosting service?
</AccordionTrigger>
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
Using our hosted version is the easiest way to get started, you can simply subscribe
and start signing your documents. We take care of the infrastructure, so you can focus
on your business. Additionally, when using our hosted version you benefit from our
trusted signing certificates which helps you to build trust with your customers.
</AccordionContent>
</AccordionItem>
<AccordionItem value="how-to-contribute">
<AccordionTrigger className="text-left text-lg font-semibold">
How can I contribute?
</AccordionTrigger>
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
That's awesome. You can take a look at the current{' '}
<Link
className="text-documenso-700 font-bold"
href="https://github.com/documenso/documenso/milestones"
target="_blank"
>
Issues
</Link>{' '}
and join our{' '}
<Link
className="text-documenso-700 font-bold"
href="https://join.slack.com/t/documenso/shared_invite/zt-1vibm8txi-DqsDFtdp44Hn2H5lc~RpPQ"
target="_blank"
>
Slack Community
</Link>{' '}
to keep up to date, on what the current priorities are. In any case, we are an open
community and welcome all input, technical and non-technical ❤️
</AccordionContent>
</AccordionItem>
<AccordionItem value="can-i-use-documenso-commercially">
<AccordionTrigger className="text-left text-lg font-semibold">
Can I use Documenso commercially?
</AccordionTrigger>
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
Yes! Documenso is offered under the GNU AGPL V3 open source license. This means you
can use it for free and even modify it to fit your needs, as long as you publish your
changes under the same license.
</AccordionContent>
</AccordionItem>
<AccordionItem value="why-prefer-documenso">
<AccordionTrigger className="text-left text-lg font-semibold">
Why should I prefer Documenso over DocuSign or some other signing tool?
</AccordionTrigger>
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
Documenso is a community effort to create an open and vibrant ecosystem around a tool,
everybody is free to use and adapt. By being truly open we want to create trusted
infrastructure for the future of the internet.
</AccordionContent>
</AccordionItem>
<AccordionItem value="where-can-i-get-support">
<AccordionTrigger className="text-left text-lg font-semibold">
Where can I get support?
</AccordionTrigger>
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
We are happy to assist you at{' '}
<Link
className="text-documenso-700 font-bold"
target="_blank"
href="mailto:support@documenso.com"
>
support@documenso.com
</Link>{' '}
or{' '}
<a
className="text-documenso-700 font-bold"
href="https://join.slack.com/t/documenso/shared_invite/zt-1vibm8txi-DqsDFtdp44Hn2H5lc~RpPQ"
target="_blank"
>
in our Slack-Support-Channel
</a>{' '}
please message either Lucas or Timur to get added to the channel if you are not
already a member.
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
);
}

View File

@ -0,0 +1 @@
@import '@documenso/ui/styles/theme.css';

View File

@ -0,0 +1,53 @@
import { Inter } from 'next/font/google';
import { Toaster } from '@documenso/ui/primitives/toaster';
import { PlausibleProvider } from '~/providers/plausible';
import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
export const metadata = {
title: 'Documenso - The Open Source DocuSign Alternative',
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' },
robots: 'index, follow',
openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative',
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
type: 'website',
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`],
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={fontInter.variable} suppressHydrationWarning>
<head>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
</head>
<body>
<PlausibleProvider>
{children}
</PlausibleProvider>
<Toaster />
</body>
</html>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

View File

@ -0,0 +1,56 @@
'use client';
import Link from 'next/link';
import { Github } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { Button } from '@documenso/ui/primitives/button';
export const Callout = () => {
const event = usePlausible();
const onSignUpClick = () => {
const el = document.getElementById('email');
if (el) {
const { top } = el.getBoundingClientRect();
window.scrollTo({
top: top - 120,
behavior: 'smooth',
});
setTimeout(() => {
el.focus();
}, 500);
}
};
return (
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4">
<Button
type="button"
variant="outline"
className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
>
Get the Community Plan
<span className="bg-primary -mr-2 ml-2.5 rounded-full px-2 py-1.5 text-xs">
$30/mo. forever!
</span>
</Button>
<Link
href="https://github.com/documenso/documenso"
target="_blank"
onClick={() => event('view-github')}
>
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
<Github className="mr-2 h-5 w-5" />
Star on Github
</Button>
</Link>
</div>
);
};

View File

@ -0,0 +1,148 @@
'use client';
import React, { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Info, Loader } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { claimPlan } from '~/api/claim-plan/fetcher';
import { FormErrorMessage } from '../form/form-error-message';
export const ZClaimPlanDialogFormSchema = z.object({
name: z.string().min(3),
email: z.string().email(),
});
export type TClaimPlanDialogFormSchema = z.infer<typeof ZClaimPlanDialogFormSchema>;
export type ClaimPlanDialogProps = {
className?: string;
planId: string;
children: React.ReactNode;
};
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
const params = useSearchParams();
const { toast } = useToast();
const event = usePlausible();
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TClaimPlanDialogFormSchema>({
mode: 'onBlur',
defaultValues: {
name: params?.get('name') ?? '',
email: params?.get('email') ?? '',
},
resolver: zodResolver(ZClaimPlanDialogFormSchema),
});
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
try {
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
const [redirectUrl] = await Promise.all([
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
delay,
]);
event('claim-plan-pricing');
window.location.href = redirectUrl;
} catch (error) {
event('claim-plan-failed');
toast({
title: 'Something went wrong',
description: error instanceof Error ? error.message : 'Please try again later.',
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Claim your plan</DialogTitle>
<DialogDescription className="mt-4">
We're almost there! Please enter your email address and name to claim your plan.
</DialogDescription>
</DialogHeader>
<form
className={cn('flex flex-col gap-y-4', className)}
onSubmit={handleSubmit(onFormSubmit)}
>
{params?.get('cancelled') === 'true' && (
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<Info className="h-5 w-5 text-yellow-400" />
</div>
<div className="ml-3">
<p className="text-sm leading-5 text-yellow-700">
You have cancelled the payment process. If you didn't mean to do this, please
try again.
</p>
</div>
</div>
</div>
)}
<div>
<Label className="text-slate-500">Name</Label>
<Input type="text" className="mt-2" {...register('name')} autoFocus />
<FormErrorMessage className="mt-1" error={errors.name} />
</div>
<div>
<Label className="text-slate-500">Email</Label>
<Input type="email" className="mt-2" {...register('email')} />
<FormErrorMessage className="mt-1" error={errors.email} />
</div>
<Button type="submit" size="lg" disabled={isSubmitting}>
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
Claim the Community Plan ({/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
? 'Monthly'
: 'Yearly'}
)
</Button>
</form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,77 @@
import { HTMLAttributes } from 'react';
import Image from 'next/image';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import backgroundPattern from '~/assets/background-pattern.png';
import cardBeautifulFigure from '~/assets/card-beautiful-figure.png';
import cardFastFigure from '~/assets/card-fast-figure.png';
import cardSmartFigure from '~/assets/card-smart-figure.png';
export type FasterSmarterBeautifulBentoProps = HTMLAttributes<HTMLDivElement>;
export const FasterSmarterBeautifulBento = ({
className,
...props
}: FasterSmarterBeautifulBentoProps) => {
return (
<div className={cn('relative', className)} {...props}>
<div className="absolute inset-0 -z-10 flex items-center justify-center">
<Image
src={backgroundPattern}
alt="background pattern"
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
/>
</div>
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
A 10x better signing experience.
<span className="block md:mt-0">Faster, smarter and more beautiful.</span>
</h2>
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
<Card className="col-span-2" degrees={45} gradient>
<CardContent className="grid grid-cols-12 gap-8 overflow-hidden p-6 lg:aspect-[2.5/1]">
<p className="col-span-12 leading-relaxed text-[#555E67] lg:col-span-6">
<strong className="block">Fast.</strong>
When it comes to sending or receiving a contract, you can count on lightning-fast
speeds.
</p>
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
<Image src={cardFastFigure} alt="its fast" className="max-w-[80%] lg:max-w-none" />
</div>
</CardContent>
</Card>
<Card className="col-span-2 lg:col-span-1" spotlight>
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="leading-relaxed text-[#555E67]">
<strong className="block">Beautiful.</strong>
Because signing should be celebrated. Thats why we care about the smallest detail in
our product.
</p>
<div className="flex items-center justify-center p-8">
<Image src={cardBeautifulFigure} alt="its fast" className="w-full max-w-xs" />
</div>
</CardContent>
</Card>
<Card className="col-span-2 lg:col-span-1" spotlight>
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="leading-relaxed text-[#555E67]">
<strong className="block">Smart.</strong>
Our custom templates come with smart rules that can help you save time and energy.
</p>
<div className="flex items-center justify-center p-8">
<Image src={cardSmartFigure} alt="its fast" className="w-full max-w-[16rem]" />
</div>
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -0,0 +1,86 @@
import { HTMLAttributes } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Github, Slack, Twitter } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
export type FooterProps = HTMLAttributes<HTMLDivElement>;
export const Footer = ({ className, ...props }: FooterProps) => {
return (
<div className={cn('border-t py-12', className)} {...props}>
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
<div>
<Link href="/">
<Image src="/logo.png" alt="Documenso Logo" width={170} height={0}></Image>
</Link>
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-4 text-[#8D8D8D]">
<Link
href="https://twitter.com/documenso"
target="_blank"
className="hover:text-[#6D6D6D]"
>
<Twitter className="h-6 w-6" />
</Link>
<Link
href="https://github.com/documenso/documenso"
target="_blank"
className="hover:text-[#6D6D6D]"
>
<Github className="h-6 w-6" />
</Link>
<Link
href="https://documenso.slack.com"
target="_blank"
className="hover:text-[#6D6D6D]"
>
<Slack className="h-6 w-6" />
</Link>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
<Link
href="/pricing"
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
>
Pricing
</Link>
<Link
href="https://status.documenso.com"
target="_blank"
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
>
Status
</Link>
<Link
href="mailto:support@documenso.com"
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
>
Support
</Link>
{/* <Link
href="/privacy"
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
>
Privacy
</Link> */}
</div>
</div>
<div className="mx-auto mt-4 w-full max-w-screen-xl px-8 md:mt-12 lg:mt-24">
<p className="text-sm text-[#8D8D8D]">
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,32 @@
import { HTMLAttributes } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { cn } from '@documenso/ui/lib/utils';
export type HeaderProps = HTMLAttributes<HTMLElement>;
export const Header = ({ className, ...props }: HeaderProps) => {
return (
<header className={cn('flex items-center justify-between', className)} {...props}>
<Link href="/">
<Image src="/logo.png" alt="Documenso Logo" width={170} height={0}></Image>
</Link>
<div className="flex items-center gap-x-6">
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
Pricing
</Link>
<Link
href="https://app.documenso.com/login"
target="_blank"
className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]"
>
Sign in
</Link>
</div>
</header>
);
};

View File

@ -0,0 +1,217 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { Variants, motion } from 'framer-motion';
import { Github } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import backgroundPattern from '~/assets/background-pattern.png';
import { Widget } from './widget';
export type HeroProps = {
className?: string;
[key: string]: unknown;
};
const BackgroundPatternVariants: Variants = {
initial: {
opacity: 0,
},
animate: {
opacity: 1,
transition: {
delay: 1,
duration: 1.2,
},
},
};
const HeroTitleVariants: Variants = {
initial: {
opacity: 0,
y: 60,
},
animate: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
},
},
};
export const Hero = ({ className, ...props }: HeroProps) => {
const event = usePlausible();
const onSignUpClick = () => {
const el = document.getElementById('email');
if (el) {
const { top } = el.getBoundingClientRect();
window.scrollTo({
top: top - 120,
behavior: 'smooth',
});
requestAnimationFrame(() => {
el.focus();
});
}
};
return (
<motion.div className={cn('relative', className)} {...props}>
<div className="absolute -inset-24 -z-10">
<motion.div
className="flex h-full w-full origin-top-right items-center justify-center"
variants={BackgroundPatternVariants}
initial="initial"
animate="animate"
>
<Image
src={backgroundPattern}
alt="background pattern"
className="-mr-[50vw] -mt-[15vh] h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
/>
</motion.div>
</div>
<div className="relative">
<motion.h2
variants={HeroTitleVariants}
initial="initial"
animate="animate"
className="text-center text-4xl font-bold leading-tight tracking-tight lg:text-[64px]"
>
Document signing,
<span className="block" /> finally open source.
</motion.h2>
<motion.div
variants={HeroTitleVariants}
initial="initial"
animate="animate"
className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4"
>
<Button
type="button"
variant="outline"
className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
>
Get the Community Plan
<span className="bg-primary -mr-2 ml-2.5 rounded-full px-2 py-1.5 text-xs">
$30/mo. forever!
</span>
</Button>
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
<Github className="mr-2 h-5 w-5" />
Star on Github
</Button>
</Link>
</motion.div>
<motion.div
variants={HeroTitleVariants}
initial="initial"
animate="animate"
className="mt-8 flex flex-col items-center justify-center gap-x-6 gap-y-4"
>
<Link
href="https://www.producthunt.com/posts/documenso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-documenso"
target="_blank"
>
<img
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=395047&theme=light&period=daily"
alt="Documenso - The open source DocuSign alternative | Product Hunt"
style={{ width: '250px', height: '54px' }}
/>
</Link>
</motion.div>
<motion.div
className="mt-12"
variants={{
initial: {
scale: 0.2,
opacity: 0,
},
animate: {
scale: 1,
opacity: 1,
transition: {
ease: 'easeInOut',
delay: 0.5,
duration: 0.8,
},
},
}}
initial="initial"
animate="animate"
>
<Widget className="mt-12">
<strong>Documenso Supporter Pledge</strong>
<p className="w-full max-w-[70ch]">
Our mission is to create an open signing infrastructure that empowers the world,
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 className="w-full max-w-[70ch]">
Through our platform, called Documenso, we strive to earn your trust by allowing
self-hosting and providing complete visibility into its inner workings. We value
inclusivity and foster an environment where diverse perspectives and contributions are
welcomed, even though we may not implement them all.
</p>
<p className="w-full max-w-[70ch]">
At Documenso, we envision a web-enabled future for business and contracts, and we are
committed to being the leading provider of open signing infrastructure. By combining
exceptional product design with open-source principles, we aim to deliver a robust and
well-designed application that exceeds your expectations.
</p>
<p className="w-full max-w-[70ch]">
We understand that exceptional products are born from exceptional communities, and we
invite you to join our open-source community. Your contributions, whether technical or
non-technical, will help shape the future of signing. Together, we can create a better
future for everyone.
</p>
<p className="w-full max-w-[70ch]">
Today we invite you to join us on this journey: By signing this mission statement you
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 early supporter plan for forever, including everything we build this
year.
</p>
<div className="flex h-24 items-center">
<p className={cn('text-5xl [font-family:var(--font-caveat)]')}>Timur & Lucas</p>
</div>
<div>
<strong>Timur Ercan & Lucas Smith</strong>
<p className="mt-1">Co-Founders, Documenso</p>
</div>
</Widget>
</motion.div>
</div>
</motion.div>
);
};

View File

@ -0,0 +1,74 @@
import { HTMLAttributes } from 'react';
import Image from 'next/image';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import backgroundPattern from '~/assets/background-pattern.png';
import cardBuildFigure from '~/assets/card-build-figure.png';
import cardOpenFigure from '~/assets/card-open-figure.png';
import cardTemplateFigure from '~/assets/card-template-figure.png';
export type OpenBuildTemplateBentoProps = HTMLAttributes<HTMLDivElement>;
export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplateBentoProps) => {
return (
<div className={cn('relative', className)} {...props}>
<div className="absolute inset-0 -z-10 flex items-center justify-center">
<Image
src={backgroundPattern}
alt="background pattern"
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
/>
</div>
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
Truly your own.
<span className="block md:mt-0">Customise and expand.</span>
</h2>
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
<Card className="col-span-2" degrees={45} gradient>
<CardContent className="grid grid-cols-12 gap-8 overflow-hidden p-6 lg:aspect-[2.5/1]">
<p className="col-span-12 leading-relaxed text-[#555E67] lg:col-span-6">
<strong className="block">Open Source or Hosted.</strong>
Its up to you. Either clone our repository or rely on our easy to use hosting
solution.
</p>
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
<Image src={cardOpenFigure} alt="its fast" className="max-w-[80%] lg:max-w-full" />
</div>
</CardContent>
</Card>
<Card className="col-span-2 lg:col-span-1" spotlight>
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="leading-relaxed text-[#555E67]">
<strong className="block">Build on top.</strong>
Make it your own through advanced customization and adjustability.
</p>
<div className="flex items-center justify-center p-8">
<Image src={cardBuildFigure} alt="its fast" className="w-full max-w-xs" />
</div>
</CardContent>
</Card>
<Card className="col-span-2 lg:col-span-1" spotlight>
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="leading-relaxed text-[#555E67]">
<strong className="block">Template Store (Soon).</strong>
Choose a template from the community app store. Or submit your own template for others
to use.
</p>
<div className="flex items-center justify-center p-8">
<Image src={cardTemplateFigure} alt="its fast" className="w-full max-w-sm" />
</div>
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -0,0 +1,33 @@
'use client';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
export type PasswordRevealProps = {
password: string;
};
export const PasswordReveal = ({ password }: PasswordRevealProps) => {
const { toast } = useToast();
const [, copy] = useCopyToClipboard();
const onCopyClick = () => {
copy(password).then(() => {
toast({
title: 'Copied to clipboard',
description: 'Your password has been copied to your clipboard.',
});
});
};
return (
<button
type="button"
className="px-2 blur-sm hover:opacity-50 hover:blur-none"
onClick={onCopyClick}
>
{password}
</button>
);
};

View File

@ -0,0 +1,179 @@
'use client';
import { HTMLAttributes, useMemo, useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { AnimatePresence, motion } from 'framer-motion';
import { usePlausible } from 'next-plausible';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { ClaimPlanDialog } from './claim-plan-dialog';
export type PricingTableProps = HTMLAttributes<HTMLDivElement>;
const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar';
export const PricingTable = ({ className, ...props }: PricingTableProps) => {
const params = useSearchParams();
const event = usePlausible();
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() =>
// eslint-disable-next-line turbo/no-undeclared-env-vars
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
? 'YEARLY'
: 'MONTHLY',
);
const planId = useMemo(() => {
if (period === 'MONTHLY') {
// eslint-disable-next-line turbo/no-undeclared-env-vars
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
}
// eslint-disable-next-line turbo/no-undeclared-env-vars
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID;
}, [period]);
return (
<div className={cn('', className)} {...props}>
<div className="flex items-center justify-center gap-x-6">
<AnimatePresence>
<motion.button
key="MONTHLY"
className={cn('relative flex items-center gap-x-2.5 px-1 py-2.5 text-[#727272]', {
'text-slate-900': period === 'MONTHLY',
'hover:text-slate-900/80': period !== 'MONTHLY',
})}
onClick={() => setPeriod('MONTHLY')}
>
Monthly
{period === 'MONTHLY' && (
<motion.div
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
/>
)}
</motion.button>
<motion.button
key="YEARLY"
className={cn('relative flex items-center gap-x-2.5 px-1 py-2.5 text-[#727272]', {
'text-slate-900': period === 'YEARLY',
'hover:text-slate-900/80': period !== 'YEARLY',
})}
onClick={() => setPeriod('YEARLY')}
>
Yearly
<div className="block rounded-full bg-slate-200 px-2 py-0.5 text-xs text-slate-700">
Save $60
</div>
{period === 'YEARLY' && (
<motion.div
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
/>
)}
</motion.button>
</AnimatePresence>
</div>
<div className="mt-12 grid grid-cols-1 gap-x-6 gap-y-12 md:grid-cols-2 lg:grid-cols-3">
<div
data-plan="self-hosted"
className="flex flex-col items-center justify-center rounded-lg border bg-white px-8 py-12 shadow-lg shadow-slate-900/5"
>
<p className="text-4xl font-medium text-slate-900">Self Hosted</p>
<p className="text-primary mt-2.5 text-xl font-medium">Free</p>
<p className="mt-4 max-w-[30ch] text-center text-slate-900">
For small teams and individuals who need a simple solution
</p>
<Link
href="https://github.com/documenso/documenso"
target="_blank"
className="mt-6"
onClick={() => event('view-github')}
>
<Button className="rounded-full text-base">View on Github</Button>
</Link>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="py-4 font-medium text-slate-900">Host your own instance</p>
<p className="py-4 text-slate-900">Full Control</p>
<p className="py-4 text-slate-900">Customizability</p>
<p className="py-4 text-slate-900">Docker Ready</p>
<p className="py-4 text-slate-900">Community Support</p>
<p className="py-4 text-slate-900">Free, Forever</p>
</div>
</div>
<div
data-plan="community"
className="border-primary flex flex-col items-center justify-center rounded-lg border-2 bg-white px-8 py-12 shadow-[0px_0px_0px_4px_#E3E3E380] shadow-slate-900/5"
>
<p className="text-4xl font-medium text-slate-900">Community</p>
<div className="text-primary mt-2.5 text-xl font-medium">
<AnimatePresence mode="wait">
{period === 'MONTHLY' && <motion.div layoutId="pricing">$30</motion.div>}
{period === 'YEARLY' && <motion.div layoutId="pricing">$300</motion.div>}
</AnimatePresence>
</div>
<p className="mt-4 max-w-[30ch] text-center text-slate-900">
For fast-growing companies that aim to scale across multiple teams.
</p>
<ClaimPlanDialog planId={planId}>
<Button className="mt-6 rounded-full text-base">Signup Now</Button>
</ClaimPlanDialog>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="py-4 font-medium text-slate-900">Documenso Early Adopter Deal:</p>
<p className="py-4 text-slate-900">Join the movement</p>
<p className="py-4 text-slate-900">Simple signing solution</p>
<p className="py-4 text-slate-900">Email and Slack assistance</p>
<p className="py-4 text-slate-900">
<strong>Includes all upcoming features</strong>
</p>
<p className="py-4 text-slate-900">Fixed, straightforward pricing</p>
</div>
</div>
<div
data-plan="enterprise"
className="flex flex-col items-center justify-center rounded-lg border bg-white px-8 py-12 shadow-lg shadow-slate-900/5"
>
<p className="text-4xl font-medium text-slate-900">Enterprise</p>
<p className="text-primary mt-2.5 text-xl font-medium">Pricing on request</p>
<p className="mt-4 max-w-[30ch] text-center text-slate-900">
For large organizations that need extra flexibility and control.
</p>
<Link
href="https://dub.sh/enterprise"
target="_blank"
className="mt-6"
onClick={() => event('enterprise-contact')}
>
<Button className="rounded-full text-base">Contact Us</Button>
</Link>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="py-4 font-medium text-slate-900">Everything in Community, plus:</p>
<p className="py-4 text-slate-900">Custom Subdomain</p>
<p className="py-4 text-slate-900">Compliance Check</p>
<p className="py-4 text-slate-900">Guaranteed Uptime</p>
<p className="py-4 text-slate-900">Reporting & Analysis</p>
<p className="py-4 text-slate-900">24/7 Support</p>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,91 @@
import { HTMLAttributes } from 'react';
import Image from 'next/image';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import backgroundPattern from '~/assets/background-pattern.png';
import cardConnectionsFigure from '~/assets/card-connections-figure.png';
import cardPaidFigure from '~/assets/card-paid-figure.png';
import cardSharingFigure from '~/assets/card-sharing-figure.png';
import cardWidgetFigure from '~/assets/card-widget-figure.png';
export type ShareConnectPaidWidgetBentoProps = HTMLAttributes<HTMLDivElement>;
export const ShareConnectPaidWidgetBento = ({
className,
...props
}: ShareConnectPaidWidgetBentoProps) => {
return (
<div className={cn('relative', className)} {...props}>
<div className="absolute inset-0 -z-10 flex items-center justify-center">
<Image
src={backgroundPattern}
alt="background pattern"
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
/>
</div>
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
Integrates with all your favourite tools.
<span className="block md:mt-0">Send, connect, receive and embed everywhere.</span>
</h2>
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
<Card className="col-span-2 lg:col-span-1" degrees={120} gradient>
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="leading-relaxed text-[#555E67]">
<strong className="block">Easy Sharing (Soon).</strong>
Receive your personal link to share with everyone you care about.
</p>
<div className="flex items-center justify-center p-8">
<Image src={cardSharingFigure} alt="its fast" className="w-full max-w-xs" />
</div>
</CardContent>
</Card>
<Card className="col-span-2 lg:col-span-1" spotlight>
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="leading-relaxed text-[#555E67]">
<strong className="block">Connections (Soon).</strong>
Create connections and automations with Zapier and more to integrate with your
favorite tools.
</p>
<div className="flex items-center justify-center p-8">
<Image src={cardConnectionsFigure} alt="its fast" className="w-full max-w-sm" />
</div>
</CardContent>
</Card>
<Card className="col-span-2 lg:col-span-1" spotlight>
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="leading-relaxed text-[#555E67]">
<strong className="block">Get paid (Soon).</strong>
Integrated payments with stripe so you dont have to worry about getting paid.
</p>
<div className="flex items-center justify-center p-8">
<Image src={cardPaidFigure} alt="its fast" className="w-full max-w-[14rem]" />
</div>
</CardContent>
</Card>
<Card className="col-span-2 lg:col-span-1" spotlight>
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="leading-relaxed text-[#555E67]">
<strong className="block">React Widget (Soon).</strong>
Easily embed Documenso into your product. Simply copy and paste our react widget into
your application.
</p>
<div className="flex items-center justify-center p-8">
<Image src={cardWidgetFigure} alt="its fast" className="w-full max-w-xs" />
</div>
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -0,0 +1,400 @@
'use client';
import { HTMLAttributes, KeyboardEvent, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { claimPlan } from '~/api/claim-plan/fetcher';
import { FormErrorMessage } from '../form/form-error-message';
import { SignaturePad } from '../signature-pad';
const ZWidgetFormSchema = z
.object({
email: z.string().email({ message: 'Please enter a valid email address.' }),
name: z.string().min(3, { message: 'Please enter a valid name.' }),
})
.and(
z.union([
z.object({
signatureDataUrl: z.string().min(1),
signatureText: z.null().or(z.string().max(0)),
}),
z.object({
signatureDataUrl: z.null().or(z.string().max(0)),
signatureText: z.string().min(1),
}),
]),
);
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
export type WidgetProps = HTMLAttributes<HTMLDivElement>;
export const Widget = ({ className, children, ...props }: WidgetProps) => {
const { toast } = useToast();
const event = usePlausible();
const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL');
const [showSigningDialog, setShowSigningDialog] = useState(false);
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
const {
control,
register,
handleSubmit,
setValue,
trigger,
watch,
formState: { errors, isSubmitting, isValid },
} = useForm<TWidgetFormSchema>({
mode: 'onChange',
defaultValues: {
email: '',
name: '',
signatureDataUrl: null,
signatureText: '',
},
resolver: zodResolver(ZWidgetFormSchema),
});
const signatureDataUrl = watch('signatureDataUrl');
const signatureText = watch('signatureText');
const stepsRemaining = useMemo(() => {
if (step === 'NAME') {
return 2;
}
if (step === 'SIGN') {
return 1;
}
return 3;
}, [step]);
const onNextStepClick = () => {
if (step === 'EMAIL') {
setStep('NAME');
setTimeout(() => {
document.querySelector<HTMLElement>('#name')?.focus();
}, 0);
}
if (step === 'NAME') {
setStep('SIGN');
setTimeout(() => {
document.querySelector<HTMLElement>('#signatureText')?.focus();
}, 0);
}
};
const onEnterPress = (callback: () => void) => {
return (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
callback();
}
};
};
const onSignatureConfirmClick = () => {
setValue('signatureDataUrl', draftSignatureDataUrl);
setValue('signatureText', '');
trigger('signatureDataUrl');
setShowSigningDialog(false);
};
const onFormSubmit = async ({
email,
name,
signatureDataUrl,
signatureText,
}: TWidgetFormSchema) => {
try {
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
// eslint-disable-next-line turbo/no-undeclared-env-vars
const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
const claimPlanInput = signatureDataUrl
? {
name,
email,
planId,
signatureDataUrl: signatureDataUrl!,
signatureText: null,
}
: {
name,
email,
planId,
signatureDataUrl: null,
signatureText: signatureText!,
};
const [result] = await Promise.all([claimPlan(claimPlanInput), delay]);
event('claim-plan-widget');
window.location.href = result;
} catch (error) {
event('claim-plan-failed');
toast({
title: 'Something went wrong',
description: error instanceof Error ? error.message : 'Please try again later.',
variant: 'destructive',
});
}
};
return (
<>
<Card
className={cn('mx-auto w-full max-w-4xl rounded-3xl before:rounded-3xl', className)}
gradient
{...props}
>
<div className="grid grid-cols-12 gap-y-8 overflow-hidden p-2 lg:gap-x-8">
<div className="col-span-12 flex flex-col gap-y-4 p-4 text-xs leading-relaxed text-[#727272] lg:col-span-7">
{children}
</div>
<form
className="col-span-12 flex flex-col rounded-2xl bg-[#F7F7F7] p-6 lg:col-span-5"
onSubmit={handleSubmit(onFormSubmit)}
>
<h3 className="text-2xl font-semibold">Sign up for the community plan</h3>
<p className="mt-2 text-xs text-[#AFAFAF]">
with Timur Ercan & Lucas Smith from Documenso
</p>
<hr className="mb-6 mt-4" />
<AnimatePresence>
<motion.div key="email">
<label htmlFor="email" className="text-lg font-semibold text-slate-900 lg:text-xl">
Whats your email?
</label>
<Controller
control={control}
name="email"
render={({ field }) => (
<div className="relative mt-2">
<Input
id="email"
type="email"
placeholder=""
className="w-full bg-white pr-16"
disabled={isSubmitting}
onKeyDown={(e) =>
field.value !== '' &&
!errors.email?.message &&
onEnterPress(onNextStepClick)(e)
}
{...field}
/>
<div className="absolute inset-y-0 right-0 p-1.5">
<Button
type="button"
className="bg-primary h-full w-14 rounded"
disabled={!field.value || !!errors.email?.message}
onClick={() => onNextStepClick()}
>
Next
</Button>
</div>
</div>
)}
/>
<FormErrorMessage error={errors.email} className="mt-1" />
</motion.div>
{(step === 'NAME' || step === 'SIGN') && (
<motion.div
key="name"
className="mt-4"
animate={{
opacity: 1,
transform: 'translateX(0)',
}}
initial={{
opacity: 0,
transform: 'translateX(-25%)',
}}
exit={{
opacity: 0,
transform: 'translateX(25%)',
}}
>
<label htmlFor="name" className="text-lg font-semibold text-slate-900 lg:text-xl">
and your name?
</label>
<Controller
control={control}
name="name"
render={({ field }) => (
<div className="relative mt-2">
<Input
id="name"
type="text"
placeholder=""
className="w-full bg-white pr-16"
disabled={isSubmitting}
onKeyDown={(e) =>
field.value !== '' &&
!errors.name?.message &&
onEnterPress(onNextStepClick)(e)
}
{...field}
/>
<div className="absolute inset-y-0 right-0 p-1.5">
<Button
type="button"
className="bg-primary h-full w-14 rounded"
disabled={!field.value || !!errors.name?.message}
onClick={() => onNextStepClick()}
>
Next
</Button>
</div>
</div>
)}
/>
<FormErrorMessage error={errors.name} className="mt-1" />
</motion.div>
)}
</AnimatePresence>
<div className="mt-12 flex-1" />
<div className="flex items-center justify-between">
<p className="text-xs text-[#AFAFAF]">{stepsRemaining} step(s) until signed</p>
<p className="block text-xs text-[#AFAFAF] md:hidden">Minimise contract</p>
</div>
<div className="relative mt-2.5 h-[2px] w-full bg-[#E9E9E9]">
<div
className={cn('bg-primary/60 absolute inset-y-0 left-0 duration-200', {
'w-1/3': stepsRemaining === 3,
'w-2/3': stepsRemaining === 2,
'w-11/12': stepsRemaining === 1,
})}
/>
</div>
<Card id="signature" className="mt-4" degrees={-140} gradient>
<CardContent
role="button"
className="relative cursor-pointer pt-6"
onClick={() => setShowSigningDialog(true)}
>
<div className="flex h-28 items-center justify-center pb-6">
{!signatureText && signatureDataUrl && (
<img src={signatureDataUrl} alt="user signature" className="h-full" />
)}
{signatureText && (
<p
className={cn(
'text-4xl font-semibold text-slate-900 [font-family:var(--font-caveat)]',
)}
>
{signatureText}
</p>
)}
</div>
<div
className="absolute inset-x-0 bottom-0 flex cursor-auto items-center justify-between px-4 pb-2"
onClick={(e) => e.stopPropagation()}
>
<Input
id="signatureText"
className="border-none p-0 text-sm text-slate-700 placeholder:text-[#D6D6D6] focus-visible:ring-0"
placeholder="Draw or type name here"
disabled={isSubmitting}
{...register('signatureText', {
onChange: (e) => {
if (e.target.value !== '') {
setValue('signatureDataUrl', null);
}
},
})}
/>
<Button
type="submit"
className="h-8 disabled:bg-[#ECEEED] disabled:text-[#C6C6C6] disabled:hover:bg-[#ECEEED]"
disabled={!isValid || isSubmitting}
>
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
Sign
</Button>
</div>
</CardContent>
</Card>
</form>
</div>
</Card>
<Dialog open={showSigningDialog} onOpenChange={setShowSigningDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add your signature</DialogTitle>
</DialogHeader>
<DialogDescription>
By signing you signal your support of Documenso's mission in a <br></br>
<strong>non-legally binding, but heartfelt way</strong>. <br></br>
<br></br>You also unlock the option to purchase the early supporter plan including
everything we build this year for fixed price.
</DialogDescription>
<SignaturePad
className="aspect-video w-full rounded-md border"
onChange={setDraftSignatureDataUrl}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowSigningDialog(false)}>
Cancel
</Button>
<Button onClick={() => onSignatureConfirmClick()}>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -0,0 +1,35 @@
import { AnimatePresence, motion } from 'framer-motion';
import { FieldError } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils';
export type FormErrorMessageProps = {
className?: string;
error: FieldError | undefined;
};
export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => {
return (
<AnimatePresence>
{error && (
<motion.p
initial={{
opacity: 0,
y: -10,
}}
animate={{
opacity: 1,
y: 0,
}}
exit={{
opacity: 0,
y: 10,
}}
className={cn('text-xs text-red-500', className)}
>
{error.message}
</motion.p>
)}
</AnimatePresence>
);
};

View File

@ -0,0 +1,321 @@
import { Point } from './point';
export class Canvas {
private readonly $canvas: HTMLCanvasElement;
private readonly $offscreenCanvas: HTMLCanvasElement;
private currentCanvasWidth = 0;
private currentCanvasHeight = 0;
private points: Point[] = [];
private onChangeHandlers: Array<(_canvas: Canvas, _cleared: boolean) => void> = [];
private isPressed = false;
private lastVelocity = 0;
private readonly VELOCITY_FILTER_WEIGHT = 0.5;
private readonly DPI = 2;
constructor(canvas: HTMLCanvasElement) {
this.$canvas = canvas;
this.$offscreenCanvas = document.createElement('canvas');
const { width, height } = this.$canvas.getBoundingClientRect();
this.currentCanvasWidth = width * this.DPI;
this.currentCanvasHeight = height * this.DPI;
this.$canvas.width = this.currentCanvasWidth;
this.$canvas.height = this.currentCanvasHeight;
Object.assign(this.$canvas.style, {
touchAction: 'none',
msTouchAction: 'none',
userSelect: 'none',
});
window.addEventListener('resize', this.onResize.bind(this));
this.$canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
this.$canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
this.$canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
this.$canvas.addEventListener('mouseenter', this.onMouseEnter.bind(this));
this.$canvas.addEventListener('mouseleave', this.onMouseLeave.bind(this));
this.$canvas.addEventListener('pointerdown', this.onMouseDown.bind(this));
this.$canvas.addEventListener('pointermove', this.onMouseMove.bind(this));
this.$canvas.addEventListener('pointerup', this.onMouseUp.bind(this));
}
/**
* Calculates the minimum stroke width as a percentage of the current canvas suitable for a signature.
*/
private minStrokeWidth(): number {
return Math.min(this.currentCanvasWidth, this.currentCanvasHeight) * 0.005;
}
/**
* Calculates the maximum stroke width as a percentage of the current canvas suitable for a signature.
*/
private maxStrokeWidth(): number {
return Math.min(this.currentCanvasWidth, this.currentCanvasHeight) * 0.035;
}
/**
* Retrieves the HTML canvas element.
*/
public getCanvas(): HTMLCanvasElement {
return this.$canvas;
}
/**
* Retrieves the 2D rendering context of the canvas.
* Throws an error if the context is not available.
*/
public getContext(): CanvasRenderingContext2D {
const ctx = this.$canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas context is not available.');
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
return ctx;
}
/**
* Handles the resize event of the canvas.
* Adjusts the canvas size and preserves the content using image data.
*/
private onResize(): void {
const { width, height } = this.$canvas.getBoundingClientRect();
const oldWidth = this.currentCanvasWidth;
const oldHeight = this.currentCanvasHeight;
const ctx = this.getContext();
const imageData = ctx.getImageData(0, 0, oldWidth, oldHeight);
this.$canvas.width = width * this.DPI;
this.$canvas.height = height * this.DPI;
this.currentCanvasWidth = width * this.DPI;
this.currentCanvasHeight = height * this.DPI;
ctx.putImageData(imageData, 0, 0, 0, 0, width * this.DPI, height * this.DPI);
}
/**
* Handles the mouse down event on the canvas.
* Adds the starting point for the signature.
*/
private onMouseDown(event: MouseEvent | PointerEvent | TouchEvent): void {
if (event.cancelable) {
event.preventDefault();
}
this.isPressed = true;
const point = Point.fromEvent(event, this.DPI);
this.addPoint(point);
}
/**
* Handles the mouse move event on the canvas.
* Adds a point to the signature if the mouse is pressed, based on the sample rate.
*/
private onMouseMove(event: MouseEvent | PointerEvent | TouchEvent): void {
if (event.cancelable) {
event.preventDefault();
}
if (!this.isPressed) {
return;
}
const point = Point.fromEvent(event, this.DPI);
if (point.distanceTo(this.points[this.points.length - 1]) > 10) {
this.addPoint(point);
}
}
/**
* Handles the mouse up event on the canvas.
* Adds the final point for the signature and resets the points array.
*/
private onMouseUp(event: MouseEvent | PointerEvent | TouchEvent, addPoint = true): void {
if (event.cancelable) {
event.preventDefault();
}
this.isPressed = false;
const point = Point.fromEvent(event, this.DPI);
if (addPoint) {
this.addPoint(point);
}
this.onChangeHandlers.forEach((handler) => handler(this, false));
this.points = [];
}
private onMouseEnter(event: MouseEvent): void {
if (event.cancelable) {
event.preventDefault();
}
event.buttons === 1 && this.onMouseDown(event);
}
private onMouseLeave(event: MouseEvent): void {
if (event.cancelable) {
event.preventDefault();
}
this.onMouseUp(event, false);
}
/**
* Adds a point to the signature and performs smoothing and drawing.
*/
private addPoint(point: Point): void {
const lastPoint = this.points[this.points.length - 1] ?? point;
this.points.push(point);
const smoothedPoints = this.smoothSignature(this.points);
let velocity = point.velocityFrom(lastPoint);
velocity =
this.VELOCITY_FILTER_WEIGHT * velocity +
(1 - this.VELOCITY_FILTER_WEIGHT) * this.lastVelocity;
const newWidth =
velocity > 0 && this.lastVelocity > 0 ? this.strokeWidth(velocity) : this.minStrokeWidth();
this.drawSmoothSignature(smoothedPoints, newWidth);
this.lastVelocity = velocity;
}
/**
* Applies a smoothing algorithm to the signature points.
*/
private smoothSignature(points: Point[]): Point[] {
const smoothedPoints: Point[] = [];
const startPoint = points[0];
const endPoint = points[points.length - 1];
smoothedPoints.push(startPoint);
for (let i = 0; i < points.length - 1; i++) {
const p0 = i > 0 ? points[i - 1] : startPoint;
const p1 = points[i];
const p2 = points[i + 1];
const p3 = i < points.length - 2 ? points[i + 2] : endPoint;
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
smoothedPoints.push(new Point(cp1x, cp1y));
smoothedPoints.push(new Point(cp2x, cp2y));
smoothedPoints.push(p2);
}
return smoothedPoints;
}
/**
* Draws the smoothed signature on the canvas.
*/
private drawSmoothSignature(points: Point[], width: number): void {
const ctx = this.getContext();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
const startPoint = points[0];
ctx.moveTo(startPoint.x, startPoint.y);
ctx.lineWidth = width;
for (let i = 1; i < points.length; i += 3) {
const cp1 = points[i];
const cp2 = points[i + 1];
const endPoint = points[i + 2];
ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, endPoint.x, endPoint.y);
}
ctx.stroke();
ctx.closePath();
}
/**
* Calculates the stroke width based on the velocity.
*/
private strokeWidth(velocity: number): number {
return Math.max(this.maxStrokeWidth() / (velocity + 1), this.minStrokeWidth());
}
public registerOnChangeHandler(handler: (_canvas: Canvas, _cleared: boolean) => void): void {
this.onChangeHandlers.push(handler);
}
public unregisterOnChangeHandler(handler: (_canvas: Canvas, _cleared: boolean) => void): void {
this.onChangeHandlers = this.onChangeHandlers.filter((l) => l !== handler);
}
/**
* Retrieves the signature as a data URL.
*/
public toDataURL(type?: string, quality?: number): string {
return this.$canvas.toDataURL(type, quality);
}
/**
* Clears the signature from the canvas.
*/
public clear(): void {
const ctx = this.getContext();
ctx.clearRect(0, 0, this.currentCanvasWidth, this.currentCanvasHeight);
this.onChangeHandlers.forEach((handler) => handler(this, true));
this.points = [];
}
/**
* Retrieves the signature as an image blob.
*/
public toBlob(type?: string, quality?: number): Promise<Blob> {
return new Promise((resolve, reject) => {
this.$canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Could not convert canvas to blob.'));
return;
}
resolve(blob);
},
type,
quality,
);
});
}
}

View File

@ -0,0 +1,29 @@
export const average = (a: number, b: number) => (a + b) / 2;
export const getSvgPathFromStroke = (points: number[][], closed = true) => {
const len = points.length;
if (len < 4) {
return ``;
}
let a = points[0];
let b = points[1];
const c = points[2];
let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(2)},${b[1].toFixed(
2,
)} ${average(b[0], c[0]).toFixed(2)},${average(b[1], c[1]).toFixed(2)} T`;
for (let i = 2, max = len - 1; i < max; i++) {
a = points[i];
b = points[i + 1];
result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(2)} `;
}
if (closed) {
result += 'Z';
}
return result;
};

View File

@ -0,0 +1 @@
export * from './signature-pad';

View File

@ -0,0 +1,98 @@
import {
MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
TouchEvent as ReactTouchEvent,
} from 'react';
export type PointLike = {
x: number;
y: number;
timestamp: number;
};
const isTouchEvent = (
event:
| ReactMouseEvent
| ReactPointerEvent
| ReactTouchEvent
| MouseEvent
| PointerEvent
| TouchEvent,
): event is TouchEvent | ReactTouchEvent => {
return 'touches' in event;
};
export class Point implements PointLike {
public x: number;
public y: number;
public timestamp: number;
constructor(x: number, y: number, timestamp?: number) {
this.x = x;
this.y = y;
this.timestamp = timestamp ?? Date.now();
}
public distanceTo(point: PointLike): number {
return Math.sqrt(Math.pow(point.x - this.x, 2) + Math.pow(point.y - this.y, 2));
}
public equals(point: PointLike): boolean {
return this.x === point.x && this.y === point.y && this.timestamp === point.timestamp;
}
public velocityFrom(start: PointLike): number {
const timeDifference = this.timestamp - start.timestamp;
if (timeDifference !== 0) {
return this.distanceTo(start) / timeDifference;
}
return 0;
}
public static fromPointLike({ x, y, timestamp }: PointLike): Point {
return new Point(x, y, timestamp);
}
public static fromEvent(
event:
| ReactMouseEvent
| ReactPointerEvent
| ReactTouchEvent
| MouseEvent
| PointerEvent
| TouchEvent,
dpi = 1,
el?: HTMLElement | null,
): Point {
const target = el ?? event.target;
if (!(target instanceof HTMLElement)) {
throw new Error('Event target is not an HTMLElement.');
}
const { top, bottom, left, right } = target.getBoundingClientRect();
let clientX, clientY;
if (isTouchEvent(event)) {
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
} else {
clientX = event.clientX;
clientY = event.clientY;
}
// create a new point snapping to the edge of the current target element if it exceeds
// the bounding box of the target element
let x = Math.min(Math.max(left, clientX), right) - left;
let y = Math.min(Math.max(top, clientY), bottom) - top;
// adjust for DPI
x *= dpi;
y *= dpi;
return new Point(x, y);
}
}

View File

@ -0,0 +1,212 @@
'use client';
import {
HTMLAttributes,
MouseEvent,
PointerEvent,
TouchEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { StrokeOptions, getStroke } from 'perfect-freehand';
import { cn } from '@documenso/ui/lib/utils';
import { getSvgPathFromStroke } from './helper';
import { Point } from './point';
const DPI = 2;
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
onChange?: (_signatureDataUrl: string | null) => void;
};
export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProps) => {
const $el = useRef<HTMLCanvasElement>(null);
const [isPressed, setIsPressed] = useState(false);
const [points, setPoints] = useState<Point[]>([]);
const perfectFreehandOptions = useMemo(() => {
const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10;
return {
size,
thinning: 0.25,
streamline: 0.5,
smoothing: 0.5,
end: {
taper: size * 2,
},
} satisfies StrokeOptions;
}, []);
const onMouseDown = (event: MouseEvent | PointerEvent | TouchEvent) => {
if (event.cancelable) {
event.preventDefault();
}
setIsPressed(true);
const point = Point.fromEvent(event, DPI, $el.current);
const newPoints = [...points, point];
setPoints(newPoints);
if ($el.current) {
const ctx = $el.current.getContext('2d');
if (ctx) {
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
const pathData = new Path2D(
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
);
ctx.fill(pathData);
}
}
};
const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => {
if (event.cancelable) {
event.preventDefault();
}
if (!isPressed) {
return;
}
const point = Point.fromEvent(event, DPI, $el.current);
if (point.distanceTo(points[points.length - 1]) > 5) {
const newPoints = [...points, point];
setPoints(newPoints);
if ($el.current) {
const ctx = $el.current.getContext('2d');
if (ctx) {
ctx.restore();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
const pathData = new Path2D(
getSvgPathFromStroke(getStroke(points, perfectFreehandOptions)),
);
ctx.fill(pathData);
}
}
}
};
const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addPoint = true) => {
if (event.cancelable) {
event.preventDefault();
}
setIsPressed(false);
const point = Point.fromEvent(event, DPI, $el.current);
const newPoints = [...points];
if (addPoint) {
newPoints.push(point);
setPoints(newPoints);
}
if ($el.current) {
const ctx = $el.current.getContext('2d');
if (ctx) {
ctx.restore();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
const pathData = new Path2D(
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
);
ctx.fill(pathData);
ctx.save();
}
onChange?.($el.current.toDataURL());
}
setPoints([]);
};
const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => {
if (event.cancelable) {
event.preventDefault();
}
if ('buttons' in event && event.buttons === 1) {
onMouseDown(event);
}
};
const onMouseLeave = (event: MouseEvent | PointerEvent | TouchEvent) => {
if (event.cancelable) {
event.preventDefault();
}
onMouseUp(event, false);
};
const onClearClick = () => {
if ($el.current) {
const ctx = $el.current.getContext('2d');
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
}
onChange?.(null);
setPoints([]);
};
useEffect(() => {
if ($el.current) {
$el.current.width = $el.current.clientWidth * DPI;
$el.current.height = $el.current.clientHeight * DPI;
}
}, []);
return (
<div className="relative block">
<canvas
ref={$el}
className={cn('relative block', className)}
style={{ touchAction: 'none' }}
onPointerMove={(event) => onMouseMove(event)}
onPointerDown={(event) => onMouseDown(event)}
onPointerUp={(event) => onMouseUp(event)}
onPointerLeave={(event) => onMouseLeave(event)}
onPointerEnter={(event) => onMouseEnter(event)}
{...props}
/>
<div className="absolute bottom-2 right-2">
<button className="rounded-full p-2 text-xs text-slate-500" onClick={() => onClearClick()}>
Clear Signature
</button>
</div>
</div>
);
};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,28 @@
import { useState } from 'react';
export type CopiedValue = string | null;
export type CopyFn = (_text: string) => Promise<boolean>;
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
const copy: CopyFn = async (text) => {
if (!navigator?.clipboard) {
console.warn('Clipboard not supported');
return false;
}
// Try to save to clipboard then save it in the state if worked
try {
await navigator.clipboard.writeText(text);
setCopiedText(text);
return true;
} catch (error) {
console.warn('Copy failed', error);
setCopiedText(null);
return false;
}
};
return [copiedText, copy];
}

View File

@ -0,0 +1,128 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { randomUUID } from 'crypto';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { TClaimPlanResponseSchema, ZClaimPlanRequestSchema } from '~/api/claim-plan/types';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<TClaimPlanResponseSchema>,
) {
try {
const { method } = req;
if (method?.toUpperCase() !== 'POST') {
return res.status(405).json({
error: 'Method not allowed',
});
}
const safeBody = ZClaimPlanRequestSchema.safeParse(req.body);
if (!safeBody.success) {
return res.status(400).json({
error: 'Bad request',
});
}
const { email, name, planId, signatureDataUrl, signatureText } = safeBody.data;
const user = await prisma.user.findFirst({
where: {
email: email.toLowerCase(),
},
include: {
Subscription: true,
},
});
if (user && user.Subscription.length > 0) {
return res.status(200).json({
// eslint-disable-next-line turbo/no-undeclared-env-vars
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
});
}
const password = Math.random().toString(36).slice(2, 9);
const passwordHash = hashSync(password);
const { id: userId } = await prisma.user.upsert({
where: {
email: email.toLowerCase(),
},
create: {
email: email.toLowerCase(),
name,
password: passwordHash,
},
update: {
name,
password: passwordHash,
},
});
await redis.set(`user:${userId}:temp-password`, password, {
// expire in 24 hours
ex: 60 * 60 * 24,
});
const signatureDataUrlKey = randomUUID();
if (signatureDataUrl) {
await redis.set(`signature:${signatureDataUrlKey}`, signatureDataUrl, {
// expire in 7 days
ex: 60 * 60 * 24 * 7,
});
}
const metadata: Record<string, string> = {
name,
email,
signatureText: signatureText || name,
source: 'landing',
};
if (signatureDataUrl) {
metadata.signatureDataUrl = signatureDataUrlKey;
}
const checkout = await stripe.checkout.sessions.create({
customer_email: email,
client_reference_id: userId.toString(),
payment_method_types: ['card'],
line_items: [
{
price: planId,
quantity: 1,
},
],
mode: 'subscription',
metadata,
allow_promotion_codes: true,
// eslint-disable-next-line turbo/no-undeclared-env-vars
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
email,
)}&name=${encodeURIComponent(name)}&planId=${planId}&cancelled=true`,
});
if (!checkout.url) {
throw new Error('Checkout URL not found');
}
return res.json({
redirectUrl: checkout.url,
});
} catch (error) {
console.error(error);
return res.status(500).json({
error: 'Internal server error',
});
}
}

View File

@ -0,0 +1,173 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { randomBytes } from 'crypto';
import { readFileSync } from 'fs';
import { buffer } from 'micro';
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
import { redis } from '@documenso/lib/server-only/redis';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
FieldType,
ReadStatus,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
const log = (...args: any[]) => console.log('[stripe]', ...args);
export const config = {
api: { bodyParser: false },
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// eslint-disable-next-line turbo/no-undeclared-env-vars
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
// return res.status(500).json({
// success: false,
// message: 'Subscriptions are not enabled',
// });
// }
const sig =
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
if (!sig) {
return res.status(400).json({
success: false,
message: 'No signature found in request',
});
}
log('constructing body...');
const body = await buffer(req);
log('constructed body');
const event = stripe.webhooks.constructEvent(
body,
sig,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, turbo/no-undeclared-env-vars
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET!,
);
log('event-type:', event.type);
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
if (session.metadata?.source === 'landing') {
const user = await prisma.user.findFirst({
where: {
id: Number(session.client_reference_id),
},
});
if (!user) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
const signatureText = session.metadata?.signatureText || user.name;
let signatureDataUrl = '';
if (session.metadata?.signatureDataUrl) {
const result = await redis.get<string>(`signature:${session.metadata.signatureDataUrl}`);
if (result) {
signatureDataUrl = result;
}
}
const now = new Date();
const document = await prisma.document.create({
data: {
title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED,
userId: user.id,
document: readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'),
created: now,
},
});
const recipient = await prisma.recipient.create({
data: {
name: user.name ?? '',
email: user.email,
token: randomBytes(16).toString('hex'),
signedAt: now,
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.SIGNED,
documentId: document.id,
},
});
const field = await prisma.field.create({
data: {
documentId: document.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 0,
positionX: 77,
positionY: 638,
inserted: false,
customText: '',
},
});
if (signatureDataUrl) {
document.document = await insertImageInPDF(
document.document,
signatureDataUrl,
field.positionX,
field.positionY,
field.page,
);
} else {
document.document = await insertTextInPDF(
document.document,
signatureText ?? '',
field.positionX,
field.positionY,
field.page,
);
}
await Promise.all([
prisma.signature.create({
data: {
fieldId: field.id,
recipientId: recipient.id,
signatureImageAsBase64: signatureDataUrl || undefined,
typedSignature: signatureDataUrl ? '' : signatureText,
},
}),
prisma.document.update({
where: {
id: document.id,
},
data: {
document: document.document,
},
}),
]);
}
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
log('Unhandled webhook event', event.type);
return res.status(400).json({
success: false,
message: 'Unhandled webhook event',
});
}

View File

@ -0,0 +1,13 @@
'use client';
import React from 'react';
import NextPlausibleProvider from 'next-plausible';
export type PlausibleProviderProps = {
children: React.ReactNode;
};
export const PlausibleProvider = ({ children }: PlausibleProviderProps) => {
return <NextPlausibleProvider domain="documenso.com">{children}</NextPlausibleProvider>;
};