Compare commits
52 Commits
feat/blog-
...
feat/disab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6640f0496a | ||
|
|
de3ebe16ee | ||
|
|
84a2d3baf6 | ||
|
|
74180defd1 | ||
|
|
aeeaaf0d8d | ||
|
|
2b84293c4e | ||
|
|
b38ef6c0a7 | ||
|
|
17af4d25bd | ||
|
|
6e095921e6 | ||
|
|
150c42b246 | ||
|
|
aecf2f32b9 | ||
|
|
b23967d777 | ||
|
|
b3291c65bc | ||
|
|
4b849e286c | ||
|
|
7bcc26a987 | ||
|
|
692722d32e | ||
|
|
e4f06d8e30 | ||
|
|
c799380787 | ||
|
|
5540fcf0d2 | ||
|
|
d9da09c1e7 | ||
|
|
fe90aa3b7b | ||
|
|
0c680e0111 | ||
|
|
7bcf5fbd86 | ||
|
|
7218b950fe | ||
|
|
901013fdc6 | ||
|
|
5c9017f3cd | ||
|
|
34e962cc48 | ||
|
|
bf9254597a | ||
|
|
b5efa0d3ea | ||
|
|
a2bdb46076 | ||
|
|
ed150d9574 | ||
|
|
e756a21fda | ||
|
|
13084049da | ||
|
|
055e723777 | ||
|
|
419318c151 | ||
|
|
8529ac3ffe | ||
|
|
7ec8e762b0 | ||
|
|
2acada6dc7 | ||
|
|
d4d76dce03 | ||
|
|
3832ce2c80 | ||
|
|
fd36e39a38 | ||
|
|
3440c47c3c | ||
|
|
9257454a96 | ||
|
|
c161a8109b | ||
|
|
e340c4ed6f | ||
|
|
b5f96ee2fc | ||
|
|
3c1790ba83 | ||
|
|
f41c78e8e3 | ||
|
|
b8b8b4dbad | ||
|
|
d195dc1a46 | ||
|
|
3ac29d8da3 | ||
|
|
2418612507 |
@@ -27,7 +27,11 @@ export type ClaimedPlanPageProps = {
|
|||||||
export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlanPageProps) {
|
export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlanPageProps) {
|
||||||
const { sessionId } = searchParams;
|
const { sessionId } = searchParams;
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.retrieve(sessionId as string);
|
if (typeof sessionId !== 'string') {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -157,7 +161,6 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
href={`${process.env.NEXT_PUBLIC_APP_URL}/login`}
|
href={`${process.env.NEXT_PUBLIC_APP_URL}/login`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="mt-4 block"
|
className="mt-4 block"
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
|
|
||||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
const SOCIAL_LINKS = [
|
||||||
|
{ href: 'https://twitter.com/documenso', icon: <Twitter className="h-6 w-6" /> },
|
||||||
|
{ href: 'https://github.com/documenso/documenso', icon: <Github className="h-6 w-6" /> },
|
||||||
|
{ href: 'https://documen.so/discord', icon: <MessagesSquare className="h-6 w-6" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FOOTER_LINKS = [
|
||||||
|
{ href: '/pricing', text: 'Pricing' },
|
||||||
|
{ href: '/blog', text: 'Blog' },
|
||||||
|
{ href: '/open', text: 'Open' },
|
||||||
|
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
||||||
|
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
|
||||||
|
{ href: 'mailto:support@documenso.com', text: 'Support' },
|
||||||
|
{ href: '/privacy', text: 'Privacy' },
|
||||||
|
];
|
||||||
|
|
||||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
export const Footer = ({ className, ...props }: FooterProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn('border-t py-12', className)} {...props}>
|
<div className={cn('border-t py-12', className)} {...props}>
|
||||||
@@ -19,77 +35,25 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-4 text-[#8D8D8D]">
|
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-4 text-[#8D8D8D]">
|
||||||
<Link
|
{SOCIAL_LINKS.map((link, index) => (
|
||||||
href="https://twitter.com/documenso"
|
<Link key={index} href={link.href} target="_blank" className="hover:text-[#6D6D6D]">
|
||||||
target="_blank"
|
{link.icon}
|
||||||
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://documen.so/discord"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<MessagesSquare className="h-6 w-6" />
|
|
||||||
</Link>
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
||||||
|
{FOOTER_LINKS.map((link, index) => (
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
key={index}
|
||||||
|
href={link.href}
|
||||||
|
target={link.target}
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||||
>
|
>
|
||||||
Pricing
|
{link.text}
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/blog" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
|
|
||||||
Blog
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/open" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
|
|
||||||
Open
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://shop.documenso.com"
|
|
||||||
target="_blank"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Shop
|
|
||||||
</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>
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto mt-4 w-full max-w-screen-xl px-8 md:mt-12 lg:mt-24">
|
<div className="mx-auto mt-4 w-full max-w-screen-xl px-8 md:mt-12 lg:mt-24">
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export const MENU_NAVIGATION_LINKS = [
|
|||||||
href: '/pricing',
|
href: '/pricing',
|
||||||
text: 'Pricing',
|
text: 'Pricing',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/open',
|
||||||
|
text: 'Open',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: 'https://status.documenso.com',
|
href: 'https://status.documenso.com',
|
||||||
text: 'Status',
|
text: 'Status',
|
||||||
@@ -59,7 +63,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
transition={{
|
transition={{
|
||||||
staggerChildren: 0.2,
|
staggerChildren: 0.03,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
|
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
|
||||||
@@ -75,6 +79,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
x: 0,
|
x: 0,
|
||||||
transition: {
|
transition: {
|
||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
|
ease: 'backInOut',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
const event = usePlausible();
|
const event = usePlausible();
|
||||||
|
|
||||||
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() =>
|
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
|
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
|
||||||
? 'YEARLY'
|
? 'YEARLY'
|
||||||
: 'MONTHLY',
|
: 'MONTHLY',
|
||||||
@@ -30,11 +29,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
|
|
||||||
const planId = useMemo(() => {
|
const planId = useMemo(() => {
|
||||||
if (period === 'MONTHLY') {
|
if (period === 'MONTHLY') {
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
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;
|
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID;
|
||||||
}, [period]);
|
}, [period]);
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,6 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
setTimeout(resolve, 1000);
|
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 planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
||||||
|
|
||||||
const claimPlanInput = signatureDataUrl
|
const claimPlanInput = signatureDataUrl
|
||||||
@@ -147,7 +146,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
planId,
|
planId,
|
||||||
signatureDataUrl: signatureDataUrl!,
|
signatureDataUrl: signatureDataUrl,
|
||||||
signatureText: null,
|
signatureText: null,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@@ -155,7 +154,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
email,
|
email,
|
||||||
planId,
|
planId,
|
||||||
signatureDataUrl: null,
|
signatureDataUrl: null,
|
||||||
signatureText: signatureText!,
|
signatureText: signatureText ?? '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const [result] = await Promise.all([claimPlan(claimPlanInput), delay]);
|
const [result] = await Promise.all([claimPlan(claimPlanInput), delay]);
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export default async function handler(
|
|||||||
|
|
||||||
if (user && user.Subscription.length > 0) {
|
if (user && user.Subscription.length > 0) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -104,7 +103,6 @@ export default async function handler(
|
|||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
metadata,
|
metadata,
|
||||||
allow_promotion_codes: true,
|
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}`,
|
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -17,14 +17,13 @@ import {
|
|||||||
SigningStatus,
|
SigningStatus,
|
||||||
} from '@documenso/prisma/client';
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
const log = (...args: any[]) => console.log('[stripe]', ...args);
|
const log = (...args: unknown[]) => console.log('[stripe]', ...args);
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: { bodyParser: false },
|
api: { bodyParser: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
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) {
|
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
|
||||||
// return res.status(500).json({
|
// return res.status(500).json({
|
||||||
// success: false,
|
// success: false,
|
||||||
@@ -55,6 +54,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
log('event-type:', event.type);
|
log('event-type:', event.type);
|
||||||
|
|
||||||
if (event.type === 'checkout.session.completed') {
|
if (event.type === 'checkout.session.completed') {
|
||||||
|
// This typecast is required since we don't want to create a guard for every event type
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const session = event.data.object as Stripe.Checkout.Session;
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
|
||||||
if (session.metadata?.source === 'landing') {
|
if (session.metadata?.source === 'landing') {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Clock, File, FileCheck } from 'lucide-react';
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||||
|
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -21,6 +22,24 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
|||||||
|
|
||||||
import { UploadDocument } from './upload-document';
|
import { UploadDocument } from './upload-document';
|
||||||
|
|
||||||
|
const CARD_DATA = [
|
||||||
|
{
|
||||||
|
icon: FileCheck,
|
||||||
|
title: 'Completed',
|
||||||
|
status: InternalDocumentStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: File,
|
||||||
|
title: 'Drafts',
|
||||||
|
status: InternalDocumentStatus.DRAFT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Clock,
|
||||||
|
title: 'Pending',
|
||||||
|
status: InternalDocumentStatus.PENDING,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const user = await getRequiredServerComponentSession();
|
const user = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
@@ -39,15 +58,11 @@ export default async function DashboardPage() {
|
|||||||
<h1 className="text-4xl font-semibold">Dashboard</h1>
|
<h1 className="text-4xl font-semibold">Dashboard</h1>
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<Link href={'/documents?status=COMPLETED'} passHref>
|
{CARD_DATA.map((card) => (
|
||||||
<CardMetric icon={FileCheck} title="Completed" value={stats.COMPLETED} />
|
<Link key={card.status} href={`/documents?status=${card.status}`}>
|
||||||
</Link>
|
<CardMetric icon={card.icon} title={card.title} value={stats[card.status]} />
|
||||||
<Link href={'/documents?status=DRAFT'} passHref>
|
|
||||||
<CardMetric icon={File} title="Drafts" value={stats.DRAFT} />
|
|
||||||
</Link>
|
|
||||||
<Link href={'/documents?status=PENDING'} passHref>
|
|
||||||
<CardMetric icon={Clock} title="Pending" value={stats.PENDING} />
|
|
||||||
</Link>
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
|
|||||||
@@ -130,7 +130,13 @@ export const EditDocumentForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
toast({
|
||||||
|
title: 'Document sent',
|
||||||
|
description: 'Your document has been sent successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push('/dashboard');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
|||||||
@@ -82,14 +82,14 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!recipient} asChild>
|
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||||
<Link href={`/sign/${recipient?.token}`}>
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
Sign
|
Sign
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner} asChild>
|
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
||||||
<Link href={`/documents/${row.id}`}>
|
<Link href={`/documents/${row.id}`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
|
|||||||
96
apps/web/src/app/(signing)/sign/[token]/email-field.tsx
Normal file
96
apps/web/src/app/(signing)/sign/[token]/email-field.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Recipient } from '@documenso/prisma/client';
|
||||||
|
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useRequiredSigningContext } from './provider';
|
||||||
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
|
export type EmailFieldProps = {
|
||||||
|
field: FieldWithSignature;
|
||||||
|
recipient: Recipient;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { email: providedEmail } = useRequiredSigningContext();
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
|
trpc.field.signFieldWithToken.useMutation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
|
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||||
|
|
||||||
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
|
const onSign = async () => {
|
||||||
|
try {
|
||||||
|
await signFieldWithToken({
|
||||||
|
token: recipient.token,
|
||||||
|
fieldId: field.id,
|
||||||
|
value: providedEmail ?? '',
|
||||||
|
isBase64: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while signing the document.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemove = async () => {
|
||||||
|
try {
|
||||||
|
await removeSignedFieldWithToken({
|
||||||
|
token: recipient.token,
|
||||||
|
fieldId: field.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while removing the signature.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
||||||
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!field.inserted && (
|
||||||
|
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Email</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
|
||||||
|
</SigningFieldContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -87,9 +87,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
className="h-44 w-full"
|
className="h-44 w-full"
|
||||||
defaultValue={signature ?? undefined}
|
defaultValue={signature ?? undefined}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
console.log({
|
|
||||||
signpadValue: value,
|
|
||||||
});
|
|
||||||
setSignature(value);
|
setSignature(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
@@ -13,6 +14,7 @@ import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
import { DateField } from './date-field';
|
import { DateField } from './date-field';
|
||||||
|
import { EmailField } from './email-field';
|
||||||
import { SigningForm } from './form';
|
import { SigningForm } from './form';
|
||||||
import { NameField } from './name-field';
|
import { NameField } from './name-field';
|
||||||
import { SigningProvider } from './provider';
|
import { SigningProvider } from './provider';
|
||||||
@@ -42,10 +44,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const user = await getServerComponentSession();
|
||||||
|
|
||||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningProvider email={recipient.email} fullName={recipient.name}>
|
<SigningProvider email={recipient.email} fullName={recipient.name} signature={user?.signature}>
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
{document.title}
|
{document.title}
|
||||||
@@ -84,6 +88,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
.with(FieldType.DATE, () => (
|
.with(FieldType.DATE, () => (
|
||||||
<DateField key={field.id} field={field} recipient={recipient} />
|
<DateField key={field.id} field={field} recipient={recipient} />
|
||||||
))
|
))
|
||||||
|
.with(FieldType.EMAIL, () => (
|
||||||
|
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
.otherwise(() => null),
|
.otherwise(() => null),
|
||||||
)}
|
)}
|
||||||
</ElementVisible>
|
</ElementVisible>
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export const useRequiredSigningContext = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface SigningProviderProps {
|
export interface SigningProviderProps {
|
||||||
fullName?: string;
|
fullName?: string | null;
|
||||||
email?: string;
|
email?: string | null;
|
||||||
signature?: string;
|
signature?: string | null;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,11 +63,6 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
|
|
||||||
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
||||||
try {
|
try {
|
||||||
console.log({
|
|
||||||
providedSignature,
|
|
||||||
localSignature,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!providedSignature && !localSignature) {
|
if (!providedSignature && !localSignature) {
|
||||||
setShowSignatureModal(true);
|
setShowSignatureModal(true);
|
||||||
return;
|
return;
|
||||||
@@ -141,6 +136,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
|
|
||||||
{state === 'signed-text' && (
|
{state === 'signed-text' && (
|
||||||
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
||||||
|
{/* This optional chaining is intentional, we don't want to move the check into the condition above */}
|
||||||
{signature?.typedSignature}
|
{signature?.typedSignature}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||||
|
|
||||||
export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
|
export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
return ['', '7d', '14d', '30d'].includes(value as string);
|
return ['', '7d', '14d', '30d'].includes(value as string);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
'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 type CalloutProps = {
|
|
||||||
starCount?: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Callout = ({ starCount }: CalloutProps) => {
|
|
||||||
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.5 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
|
|
||||||
{starCount && starCount > 0 && (
|
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
|
||||||
{starCount.toLocaleString('en-US')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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. That’s 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
'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;
|
|
||||||
starCount?: number;
|
|
||||||
[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, starCount, ...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.5 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
|
|
||||||
{starCount && starCount > 0 && (
|
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
|
||||||
{starCount.toLocaleString('en-US')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-6">
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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>
|
|
||||||
It’s 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
'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 = () => {
|
|
||||||
void 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
'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>
|
|
||||||
|
|
||||||
<Button className="mt-6 rounded-full text-base">
|
|
||||||
<Link
|
|
||||||
href="https://github.com/documenso/documenso"
|
|
||||||
target="_blank"
|
|
||||||
onClick={() => event('view-github')}
|
|
||||||
>
|
|
||||||
View on Github
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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 don’t 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
'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 { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
|
||||||
|
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
|
||||||
|
|
||||||
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', '');
|
|
||||||
|
|
||||||
void 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">
|
|
||||||
What’s 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -21,7 +21,7 @@ import { FormErrorMessage } from '../form/form-error-message';
|
|||||||
|
|
||||||
export const ZProfileFormSchema = z.object({
|
export const ZProfileFormSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
signature: z.string().min(1),
|
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TProfileFormSchema = z.infer<typeof ZProfileFormSchema>;
|
export type TProfileFormSchema = z.infer<typeof ZProfileFormSchema>;
|
||||||
@@ -44,7 +44,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
} = useForm<TProfileFormSchema>({
|
} = useForm<TProfileFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
name: user.name ?? '',
|
name: user.name ?? '',
|
||||||
signature: '',
|
signature: user.signature || '',
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZProfileFormSchema),
|
resolver: zodResolver(ZProfileFormSchema),
|
||||||
});
|
});
|
||||||
@@ -118,10 +118,12 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
render={({ field: { onChange } }) => (
|
render={({ field: { onChange } }) => (
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full rounded-lg border bg-white backdrop-blur-sm dark:border-[#e2d7c5] dark:bg-[#fcf8ee]"
|
className="h-44 w-full rounded-lg border bg-white backdrop-blur-sm dark:border-[#e2d7c5] dark:bg-[#fcf8ee]"
|
||||||
|
defaultValue={user.signature ?? undefined}
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.signature} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
@@ -7,12 +11,20 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { FcGoogle } from 'react-icons/fc';
|
import { FcGoogle } from 'react-icons/fc';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
const ErrorMessages = {
|
||||||
|
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
|
||||||
|
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
|
||||||
|
[ErrorCode.USER_MISSING_PASSWORD]:
|
||||||
|
'This account appears to be using a social login method, please sign in using that method',
|
||||||
|
};
|
||||||
|
|
||||||
export const ZSignInFormSchema = z.object({
|
export const ZSignInFormSchema = z.object({
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
password: z.string().min(6).max(72),
|
password: z.string().min(6).max(72),
|
||||||
@@ -26,6 +38,7 @@ export type SignInFormProps = {
|
|||||||
|
|
||||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -39,6 +52,27 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
resolver: zodResolver(ZSignInFormSchema),
|
resolver: zodResolver(ZSignInFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const errorCode = searchParams?.get('error');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
if (isErrorCode(errorCode)) {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
description: ErrorMessages[errorCode] ?? 'An unknown error occurred',
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [errorCode, toast]);
|
||||||
|
|
||||||
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
|
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await signIn('credentials', {
|
await signIn('credentials', {
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
@@ -19,6 +20,7 @@ export const ZSignUpFormSchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
password: z.string().min(6).max(72),
|
password: z.string().min(6).max(72),
|
||||||
|
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
||||||
@@ -31,6 +33,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
control,
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
@@ -39,15 +42,16 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
signature: '',
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZSignUpFormSchema),
|
resolver: zodResolver(ZSignUpFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, email, password }: TSignUpFormSchema) => {
|
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await signup({ name, email, password });
|
await signup({ name, email, password, signature });
|
||||||
|
|
||||||
await signIn('credentials', {
|
await signIn('credentials', {
|
||||||
email,
|
email,
|
||||||
@@ -119,8 +123,19 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<SignaturePad className="mt-2 h-36 w-full rounded-lg border bg-white dark:border-[#e2d7c5] dark:bg-[#fcf8ee]" />
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="signature"
|
||||||
|
render={({ field: { onChange } }) => (
|
||||||
|
<SignaturePad
|
||||||
|
className="mt-2 h-36 w-full rounded-lg border bg-white dark:border-[#e2d7c5] dark:bg-[#fcf8ee]"
|
||||||
|
onChange={(v) => onChange(v ?? '')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.signature} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button size="lg" disabled={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
<Button size="lg" disabled={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
export default function middleware(req: NextRequest) {
|
import { getToken } from 'next-auth/jwt';
|
||||||
|
|
||||||
|
export default async function middleware(req: NextRequest) {
|
||||||
if (req.nextUrl.pathname === '/') {
|
if (req.nextUrl.pathname === '/') {
|
||||||
const redirectUrl = new URL('/documents', req.url);
|
const redirectUrl = new URL('/documents', req.url);
|
||||||
|
|
||||||
return NextResponse.redirect(redirectUrl);
|
return NextResponse.redirect(redirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (req.nextUrl.pathname.startsWith('/dashboard')) {
|
if (req.nextUrl.pathname.startsWith('/signin')) {
|
||||||
// const token = await getToken({ req });
|
const token = await getToken({ req });
|
||||||
|
|
||||||
// console.log('token', token);
|
if (token) {
|
||||||
|
const redirectUrl = new URL('/documents', req.url);
|
||||||
|
|
||||||
// if (!token) {
|
return NextResponse.redirect(redirectUrl);
|
||||||
// console.log('has no token', req.url);
|
}
|
||||||
// const redirectUrl = new URL('/signin', req.url);
|
}
|
||||||
// redirectUrl.searchParams.set('callbackUrl', req.url);
|
|
||||||
|
|
||||||
// return NextResponse.redirect(redirectUrl);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export default async function handler(
|
|||||||
|
|
||||||
if (user && user.Subscription.length > 0) {
|
if (user && user.Subscription.length > 0) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -104,7 +103,6 @@ export default async function handler(
|
|||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
metadata,
|
metadata,
|
||||||
allow_promotion_codes: true,
|
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}`,
|
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -17,14 +17,13 @@ import {
|
|||||||
SigningStatus,
|
SigningStatus,
|
||||||
} from '@documenso/prisma/client';
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
const log = (...args: any[]) => console.log('[stripe]', ...args);
|
const log = (...args: unknown[]) => console.log('[stripe]', ...args);
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: { bodyParser: false },
|
api: { bodyParser: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
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) {
|
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
|
||||||
// return res.status(500).json({
|
// return res.status(500).json({
|
||||||
// success: false,
|
// success: false,
|
||||||
@@ -55,6 +54,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
log('event-type:', event.type);
|
log('event-type:', event.type);
|
||||||
|
|
||||||
if (event.type === 'checkout.session.completed') {
|
if (event.type === 'checkout.session.completed') {
|
||||||
|
// This is required since we don't want to create a guard for every event type
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const session = event.data.object as Stripe.Checkout.Session;
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
|
||||||
if (session.metadata?.source === 'landing') {
|
if (session.metadata?.source === 'landing') {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import GoogleProvider, { GoogleProfile } from 'next-auth/providers/google';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||||
import { ErrorCodes } from './error-codes';
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||||
adapter: PrismaAdapter(prisma),
|
adapter: PrismaAdapter(prisma),
|
||||||
@@ -24,23 +24,23 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
},
|
},
|
||||||
authorize: async (credentials, _req) => {
|
authorize: async (credentials, _req) => {
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
throw new Error(ErrorCodes.CredentialsNotFound);
|
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, password } = credentials;
|
const { email, password } = credentials;
|
||||||
|
|
||||||
const user = await getUserByEmail({ email }).catch(() => {
|
const user = await getUserByEmail({ email }).catch(() => {
|
||||||
throw new Error(ErrorCodes.IncorrectEmailPassword);
|
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user.password) {
|
if (!user.password) {
|
||||||
throw new Error(ErrorCodes.UserMissingPassword);
|
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPasswordsSame = await compare(password, user.password);
|
const isPasswordsSame = await compare(password, user.password);
|
||||||
|
|
||||||
if (!isPasswordsSame) {
|
if (!isPasswordsSame) {
|
||||||
throw new Error(ErrorCodes.IncorrectEmailPassword);
|
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
export const ErrorCodes = {
|
export const isErrorCode = (code: unknown): code is ErrorCode => {
|
||||||
IncorrectEmailPassword: 'incorrect-email-password',
|
return typeof code === 'string' && code in ErrorCode;
|
||||||
UserMissingPassword: 'missing-password',
|
};
|
||||||
CredentialsNotFound: 'credentials-not-found',
|
|
||||||
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
||||||
|
|
||||||
|
export const ErrorCode = {
|
||||||
|
INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD',
|
||||||
|
USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD',
|
||||||
|
CREDENTIALS_NOT_FOUND: 'CREDENTIALS_NOT_FOUND',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -83,10 +83,7 @@ export const completeDocumentWithToken = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('documents', documents);
|
|
||||||
|
|
||||||
if (documents.count > 0) {
|
if (documents.count > 0) {
|
||||||
console.log('sealing document');
|
|
||||||
await sealDocument({ documentId: document.id });
|
await sealDocument({ documentId: document.id });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
|||||||
const doc = await PDFDocument.load(pdfData);
|
const doc = await PDFDocument.load(pdfData);
|
||||||
|
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
console.log('inserting field', {
|
|
||||||
...field,
|
|
||||||
Signature: null,
|
|
||||||
});
|
|
||||||
await insertFieldInPDF(doc, field);
|
await insertFieldInPDF(doc, field);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export const setFieldsForDocument = async ({
|
|||||||
})
|
})
|
||||||
: prisma.field.create({
|
: prisma.field.create({
|
||||||
data: {
|
data: {
|
||||||
|
// TODO: Rewrite this entire transaction because this is a mess
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
type: field.type!,
|
type: field.type!,
|
||||||
page: field.pageNumber,
|
page: field.pageNumber,
|
||||||
positionX: field.pageX,
|
positionX: field.pageX,
|
||||||
|
|||||||
@@ -35,15 +35,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
const fieldX = pageWidth * (Number(field.positionX) / 100);
|
const fieldX = pageWidth * (Number(field.positionX) / 100);
|
||||||
const fieldY = pageHeight * (Number(field.positionY) / 100);
|
const fieldY = pageHeight * (Number(field.positionY) / 100);
|
||||||
|
|
||||||
console.log({
|
|
||||||
fieldWidth,
|
|
||||||
fieldHeight,
|
|
||||||
fieldX,
|
|
||||||
fieldY,
|
|
||||||
pageWidth,
|
|
||||||
pageHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
const font = await pdf.embedFont(isSignatureField ? fontCaveat : StandardFonts.Helvetica);
|
const font = await pdf.embedFont(isSignatureField ? fontCaveat : StandardFonts.Helvetica);
|
||||||
|
|
||||||
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
||||||
@@ -75,15 +66,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||||
imageY = pageHeight - imageY - imageHeight;
|
imageY = pageHeight - imageY - imageHeight;
|
||||||
|
|
||||||
console.log({
|
|
||||||
initialDimensions,
|
|
||||||
scalingFactor,
|
|
||||||
imageWidth,
|
|
||||||
imageHeight,
|
|
||||||
imageX,
|
|
||||||
imageY,
|
|
||||||
});
|
|
||||||
|
|
||||||
page.drawImage(image, {
|
page.drawImage(image, {
|
||||||
x: imageX,
|
x: imageX,
|
||||||
y: imageY,
|
y: imageY,
|
||||||
@@ -107,17 +89,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
const textX = fieldX + (fieldWidth - textWidth) / 2;
|
const textX = fieldX + (fieldWidth - textWidth) / 2;
|
||||||
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
||||||
|
|
||||||
console.log({
|
|
||||||
initialDimensions,
|
|
||||||
scalingFactor,
|
|
||||||
textWidth,
|
|
||||||
textHeight,
|
|
||||||
textX,
|
|
||||||
textY,
|
|
||||||
pageWidth,
|
|
||||||
pageHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||||
textY = pageHeight - textY - textHeight;
|
textY = pageHeight - textY - textHeight;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
export const stripe = new Stripe(process.env.NEXT_PRIVATE_STRIPE_API_KEY ?? '', {
|
||||||
export const stripe = new Stripe(process.env.NEXT_PRIVATE_STRIPE_API_KEY!, {
|
|
||||||
apiVersion: '2022-11-15',
|
apiVersion: '2022-11-15',
|
||||||
typescript: true,
|
typescript: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ export interface CreateUserOptions {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
signature?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createUser = async ({ name, email, password }: CreateUserOptions) => {
|
export const createUser = async ({ name, email, password, signature }: 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({
|
||||||
@@ -29,6 +30,7 @@ export const createUser = async ({ name, email, password }: CreateUserOptions) =
|
|||||||
name,
|
name,
|
||||||
email: email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
|
signature,
|
||||||
identityProvider: IdentityProvider.DOCUMENSO,
|
identityProvider: IdentityProvider.DOCUMENSO,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ export const updatePassword = async ({ userId, password }: UpdatePasswordOptions
|
|||||||
|
|
||||||
const hashedPassword = await hash(password, SALT_ROUNDS);
|
const hashedPassword = await hash(password, SALT_ROUNDS);
|
||||||
|
|
||||||
|
if (user.password) {
|
||||||
// Compare the new password with the old password
|
// Compare the new password with the old password
|
||||||
const isSamePassword = await compare(password, user.password as string);
|
const isSamePassword = await compare(password, user.password);
|
||||||
|
|
||||||
if (isSamePassword) {
|
if (isSamePassword) {
|
||||||
throw new Error('Your new password cannot be the same as your old password.');
|
throw new Error('Your new password cannot be the same as your old password.');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
const updatedUser = await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -6,12 +6,7 @@ export type UpdateProfileOptions = {
|
|||||||
signature: string;
|
signature: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProfile = async ({
|
export const updateProfile = async ({ userId, name, signature }: UpdateProfileOptions) => {
|
||||||
userId,
|
|
||||||
name,
|
|
||||||
// TODO: Actually use signature
|
|
||||||
signature: _signature,
|
|
||||||
}: UpdateProfileOptions) => {
|
|
||||||
// Existence check
|
// Existence check
|
||||||
await prisma.user.findFirstOrThrow({
|
await prisma.user.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@@ -25,7 +20,7 @@ export const updateProfile = async ({
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
// signature,
|
signature,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type FindResultSet<T> = {
|
export type FindResultSet<T> = {
|
||||||
data: T extends Array<any> ? T : T[];
|
data: T extends Array<unknown> ? T : T[];
|
||||||
count: number;
|
count: number;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
perPage: number;
|
perPage: number;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const isDocumentStatus = (value: unknown): value is DocumentStatus => {
|
export const isDocumentStatus = (value: unknown): value is DocumentStatus => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
return Object.values(DocumentStatus).includes(value as DocumentStatus);
|
return Object.values(DocumentStatus).includes(value as DocumentStatus);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "signature" TEXT;
|
||||||
@@ -20,6 +20,7 @@ model User {
|
|||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
password String?
|
password String?
|
||||||
source String?
|
source String?
|
||||||
|
signature String?
|
||||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { ZSignUpMutationSchema } from './schema';
|
|||||||
export const authRouter = router({
|
export const authRouter = router({
|
||||||
signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => {
|
signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const { name, email, password } = input;
|
const { name, email, password, signature } = input;
|
||||||
|
|
||||||
return await createUser({ name, email, password });
|
return await createUser({ name, email, password, signature });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const ZSignUpMutationSchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(6),
|
password: z.string().min(6),
|
||||||
|
signature: z.string().min(1, { message: 'A signature is required.' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
|
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
|
||||||
|
|||||||
@@ -22,10 +22,13 @@
|
|||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
"NEXTAUTH_URL",
|
"NEXTAUTH_URL",
|
||||||
"NEXTAUTH_SECRET",
|
"NEXTAUTH_SECRET",
|
||||||
|
"NEXT_PUBLIC_APP_URL",
|
||||||
"NEXT_PUBLIC_SITE_URL",
|
"NEXT_PUBLIC_SITE_URL",
|
||||||
"NEXT_PUBLIC_POSTHOG_KEY",
|
"NEXT_PUBLIC_POSTHOG_KEY",
|
||||||
"NEXT_PUBLIC_POSTHOG_HOST",
|
"NEXT_PUBLIC_POSTHOG_HOST",
|
||||||
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
|
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
|
||||||
|
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID",
|
||||||
|
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID",
|
||||||
"NEXT_PRIVATE_DATABASE_URL",
|
"NEXT_PRIVATE_DATABASE_URL",
|
||||||
"NEXT_PRIVATE_NEXT_AUTH_SECRET",
|
"NEXT_PRIVATE_NEXT_AUTH_SECRET",
|
||||||
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
||||||
@@ -44,6 +47,7 @@
|
|||||||
"NEXT_PRIVATE_SMTP_APIKEY",
|
"NEXT_PRIVATE_SMTP_APIKEY",
|
||||||
"NEXT_PRIVATE_SMTP_SECURE",
|
"NEXT_PRIVATE_SMTP_SECURE",
|
||||||
"NEXT_PRIVATE_SMTP_FROM_NAME",
|
"NEXT_PRIVATE_SMTP_FROM_NAME",
|
||||||
"NEXT_PRIVATE_SMTP_FROM_ADDRESS"
|
"NEXT_PRIVATE_SMTP_FROM_ADDRESS",
|
||||||
|
"NEXT_PRIVATE_STRIPE_API_KEY"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user