Compare commits
105 Commits
feat/refac
...
feat/add-e
| Author | SHA1 | Date | |
|---|---|---|---|
| b3291c65bc | |||
| 4b849e286c | |||
| 7bcf5fbd86 | |||
| 7218b950fe | |||
| 901013fdc6 | |||
| 5c9017f3cd | |||
| 34e962cc48 | |||
| bf9254597a | |||
| b5efa0d3ea | |||
| a2bdb46076 | |||
| ed150d9574 | |||
| e756a21fda | |||
| 13084049da | |||
| 055e723777 | |||
| 419318c151 | |||
| 7722e63e1b | |||
| 8529ac3ffe | |||
| 7ec8e762b0 | |||
| 2acada6dc7 | |||
| d4d76dce03 | |||
| 3832ce2c80 | |||
| fd36e39a38 | |||
| 14fd0eb906 | |||
| af6c62d0bf | |||
| 8d7d6a19e7 | |||
| 463dc48ea6 | |||
| d8f6a25059 | |||
| 93962625ed | |||
| 249211bd4f | |||
| bfe0d50661 | |||
| 5d4a07bcc5 | |||
| d28bb5de99 | |||
| 83a83164d4 | |||
| d71e43c5d6 | |||
| ed6fa4dc2a | |||
| 4f3970c361 | |||
| 40767430d9 | |||
| 1edfe9548d | |||
| fead48c2f0 | |||
| 0abd3da7fd | |||
| 2f78922421 | |||
| 3df0f61947 | |||
| 8e42dcb7ee | |||
| 1888ee97e6 | |||
| 068aef665d | |||
| 2772fc1678 | |||
| 8c4120f0a2 | |||
| 9f93af6134 | |||
| 3440c47c3c | |||
| 68a5a9da1e | |||
| 1f8d5e45e1 | |||
| 8fd9730e2b | |||
| 04f6df6839 | |||
| ca40e983e3 | |||
| 9257454a96 | |||
| ba054ae915 | |||
| 1d1c6e5a55 | |||
| c161a8109b | |||
| e340c4ed6f | |||
| b5f96ee2fc | |||
| 3c1790ba83 | |||
| f41c78e8e3 | |||
| b8b8b4dbad | |||
| d195dc1a46 | |||
| 3ac29d8da3 | |||
| 2418612507 | |||
| e8336ae9b4 | |||
| aad52a3e2e | |||
| 829122c486 | |||
| 090752c539 | |||
| fad6414995 | |||
| c817c67a1c | |||
| c7001e62f3 | |||
| bf71d2a14e | |||
| 163911255e | |||
| 24e38a3bbc | |||
| dfd714f16a | |||
| 722081f89e | |||
| f0e1df22b8 | |||
| 615cb263fb | |||
| 18faaf49d9 | |||
| 650b69ae56 | |||
| eb4be963e3 | |||
| 27c27743e3 | |||
| 92930a2f63 | |||
| 7ad3365b0e | |||
| f8bf4fea36 | |||
| 10cd8144eb | |||
| 66973a3745 | |||
| 85677bb792 | |||
| 7ae99d2038 | |||
| 70a5105783 | |||
| 420372ac9e | |||
| 6b00282a87 | |||
| dae1001cbb | |||
| af81d99b2a | |||
| 2751adc463 | |||
| 396ce9f3f3 | |||
| 3f4f66d878 | |||
| d6751d7a26 | |||
| 0e32baff0b | |||
| f76bf4c2c7 | |||
| 0d8532ab6d | |||
| 490d3d51e1 | |||
| 04f9422f24 |
@ -12,6 +12,8 @@ NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
|
||||
# [[DATABASE]]
|
||||
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
||||
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||
|
||||
# [[SMTP]]
|
||||
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
||||
|
||||
7
.eslintignore
Normal file
@ -0,0 +1,7 @@
|
||||
# Config files
|
||||
*.config.js
|
||||
*.config.cjs
|
||||
|
||||
# Statically hosted javascript files
|
||||
apps/*/public/*.js
|
||||
apps/*/public/*.cjs
|
||||
7
.github/dependabot.yml
vendored
@ -1,12 +1,5 @@
|
||||
version: 2
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "feat/refresh" ]
|
||||
pull_request:
|
||||
branches: [ "feat/refresh" ]
|
||||
workflow_dispatch:
|
||||
|
||||
updates:
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
|
||||
21
.github/workflows/semantic-pull-requests.yml
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
name: "Validate PR Name"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
validate-pr:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"dev": "PORT=3001 next dev",
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
@ -30,6 +30,7 @@
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
"recharts": "^2.7.2",
|
||||
"sharp": "0.32.5",
|
||||
"typescript": "5.1.6",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
|
||||
@ -5,7 +5,7 @@ import { allDocuments } from 'contentlayer/generated';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
export const generateStaticParams = async () =>
|
||||
export const generateStaticParams = () =>
|
||||
allDocuments.map((post) => ({ post: post._raw.flattenedPath }));
|
||||
|
||||
export const generateMetadata = ({ params }: { params: { content: string } }) => {
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
import { ImageResponse } from 'next/server';
|
||||
|
||||
import { allBlogPosts } from 'contentlayer/generated';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
};
|
||||
|
||||
export const contentType = 'image/png';
|
||||
|
||||
type BlogPostOpenGraphImageProps = {
|
||||
params: { post: string };
|
||||
};
|
||||
|
||||
export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGraphImageProps) {
|
||||
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
|
||||
|
||||
if (!blogPost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The long urls are needed for a compiler optimisation on the Next.js side, lifting this up
|
||||
// to a constant will break og image generation.
|
||||
const [interBold, interRegular, backgroundImage, logoImage] = await Promise.all([
|
||||
fetch(new URL('./../../../../assets/inter-bold.ttf', import.meta.url)).then(async (res) =>
|
||||
res.arrayBuffer(),
|
||||
),
|
||||
fetch(new URL('./../../../../assets/inter-regular.ttf', import.meta.url)).then(async (res) =>
|
||||
res.arrayBuffer(),
|
||||
),
|
||||
fetch(new URL('./../../../../assets/background-blog-og.png', import.meta.url)).then(
|
||||
async (res) => res.arrayBuffer(),
|
||||
),
|
||||
fetch(new URL('./../../../../../public/logo.png', import.meta.url)).then(async (res) =>
|
||||
res.arrayBuffer(),
|
||||
),
|
||||
]);
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div tw="relative h-full w-full flex flex-col items-center justify-center text-center">
|
||||
{/* @ts-expect-error Lack of typing from ImageResponse */}
|
||||
<img src={backgroundImage} alt="og-background" tw="absolute inset-0 w-full h-full" />
|
||||
|
||||
{/* @ts-expect-error Lack of typing from ImageResponse */}
|
||||
<img src={logoImage} alt="logo" tw="h-8" />
|
||||
|
||||
<h1 tw="mt-8 text-6xl text-center flex items-center justify-center w-full max-w-[800px] font-bold text-center mx-auto">
|
||||
{blogPost.title}
|
||||
</h1>
|
||||
|
||||
<p tw="font-normal">Written by {blogPost.authorName}</p>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
fonts: [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: interRegular,
|
||||
style: 'normal',
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: interBold,
|
||||
style: 'normal',
|
||||
weight: 700,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -7,7 +7,7 @@ import { ChevronLeft } from 'lucide-react';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
export const generateStaticParams = async () =>
|
||||
export const generateStaticParams = () =>
|
||||
allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
|
||||
|
||||
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||
@ -17,7 +17,9 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return { title: `Documenso - ${blogPost.title}` };
|
||||
return {
|
||||
title: `Documenso - ${blogPost.title}`,
|
||||
};
|
||||
};
|
||||
|
||||
const mdxComponents: MDXComponents = {
|
||||
|
||||
@ -27,7 +27,11 @@ export type ClaimedPlanPageProps = {
|
||||
export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlanPageProps) {
|
||||
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({
|
||||
where: {
|
||||
@ -157,7 +161,6 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
||||
</p>
|
||||
|
||||
<Link
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
href={`${process.env.NEXT_PUBLIC_APP_URL}/login`}
|
||||
target="_blank"
|
||||
className="mt-4 block"
|
||||
|
||||
@ -8,7 +8,7 @@ import { FundingRaised } from './funding-raised';
|
||||
import { GithubMetric } from './gh-metrics';
|
||||
import { TeamMembers } from './team-members';
|
||||
|
||||
export const revalidate = 86400;
|
||||
export const revalidate = 3600;
|
||||
|
||||
const ZGithubStatsResponse = z.object({
|
||||
stargazers_count: z.number(),
|
||||
@ -43,7 +43,7 @@ export default async function OpenPage() {
|
||||
accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZGithubStatsResponse.parse(res));
|
||||
|
||||
const { total_count: mergedPullRequests } = await fetch(
|
||||
@ -54,7 +54,7 @@ export default async function OpenPage() {
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZMergedPullRequestsResponse.parse(res));
|
||||
|
||||
const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', {
|
||||
@ -62,7 +62,7 @@ export default async function OpenPage() {
|
||||
accept: 'application/json',
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZStargazersLiveResponse.parse(res));
|
||||
|
||||
return (
|
||||
|
||||
@ -24,7 +24,7 @@ export default async function IndexPage() {
|
||||
accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => (typeof res.stargazers_count === 'number' ? res.stargazers_count : undefined))
|
||||
.catch(() => undefined);
|
||||
|
||||
|
||||
14
apps/marketing/src/app/robots.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/*',
|
||||
disallow: ['/_next/*'],
|
||||
},
|
||||
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
41
apps/marketing/src/app/sitemap.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
import { allBlogPosts, allGenericPages } from 'contentlayer/generated';
|
||||
|
||||
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = getBaseUrl();
|
||||
const lastModified = new Date();
|
||||
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified,
|
||||
},
|
||||
...allGenericPages.map((doc) => ({
|
||||
url: `${baseUrl}/${doc._raw.flattenedPath}`,
|
||||
lastModified,
|
||||
})),
|
||||
{
|
||||
url: `${baseUrl}/blog`,
|
||||
lastModified,
|
||||
},
|
||||
...allBlogPosts.map((doc) => ({
|
||||
url: `${baseUrl}/${doc._raw.flattenedPath}`,
|
||||
lastModified,
|
||||
})),
|
||||
{
|
||||
url: `${baseUrl}/open`,
|
||||
lastModified,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/oss-friends`,
|
||||
lastModified,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/pricing`,
|
||||
lastModified,
|
||||
},
|
||||
];
|
||||
}
|
||||
BIN
apps/marketing/src/assets/background-blog-og.png
Normal file
|
After Width: | Height: | Size: 896 KiB |
BIN
apps/marketing/src/assets/inter-bold.ttf
Normal file
BIN
apps/marketing/src/assets/inter-regular.ttf
Normal file
@ -5,7 +5,7 @@ import React, { useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Info, Loader } from 'lucide-react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
@ -63,7 +63,9 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
||||
|
||||
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
|
||||
try {
|
||||
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
||||
const delay = new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
|
||||
const [redirectUrl] = await Promise.all([
|
||||
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
|
||||
@ -85,7 +87,7 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
@ -97,50 +99,49 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
||||
</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>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting} className={cn('flex flex-col gap-y-4', className)}>
|
||||
{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">Name</Label>
|
||||
<div>
|
||||
<Label className="text-slate-500">Email</Label>
|
||||
|
||||
<Input type="text" className="mt-2" {...register('name')} autoFocus />
|
||||
<Input type="email" className="mt-2" {...register('email')} />
|
||||
|
||||
<FormErrorMessage className="mt-1" error={errors.name} />
|
||||
</div>
|
||||
<FormErrorMessage className="mt-1" error={errors.email} />
|
||||
</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>
|
||||
<Button type="submit" size="lg" loading={isSubmitting}>
|
||||
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>
|
||||
</fieldset>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -9,6 +9,22 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
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) => {
|
||||
return (
|
||||
<div className={cn('border-t py-12', className)} {...props}>
|
||||
@ -19,77 +35,25 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
</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://documen.so/discord"
|
||||
target="_blank"
|
||||
className="hover:text-[#6D6D6D]"
|
||||
>
|
||||
<MessagesSquare className="h-6 w-6" />
|
||||
</Link>
|
||||
{SOCIAL_LINKS.map((link, index) => (
|
||||
<Link key={index} href={link.href} target="_blank" className="hover:text-[#6D6D6D]">
|
||||
{link.icon}
|
||||
</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="/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>
|
||||
{FOOTER_LINKS.map((link, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 w-full max-w-screen-xl px-8 md:mt-12 lg:mt-24">
|
||||
|
||||
@ -13,7 +13,7 @@ export const PasswordReveal = ({ password }: PasswordRevealProps) => {
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
const onCopyClick = () => {
|
||||
copy(password).then(() => {
|
||||
void copy(password).then(() => {
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
description: 'Your password has been copied to your clipboard.',
|
||||
|
||||
@ -22,7 +22,6 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
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',
|
||||
@ -30,11 +29,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
|
||||
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]);
|
||||
|
||||
|
||||
@ -124,7 +124,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
setValue('signatureDataUrl', draftSignatureDataUrl);
|
||||
setValue('signatureText', '');
|
||||
|
||||
trigger('signatureDataUrl');
|
||||
void trigger('signatureDataUrl');
|
||||
setShowSigningDialog(false);
|
||||
};
|
||||
|
||||
@ -135,9 +135,10 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
signatureText,
|
||||
}: TWidgetFormSchema) => {
|
||||
try {
|
||||
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
||||
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
|
||||
@ -145,7 +146,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
name,
|
||||
email,
|
||||
planId,
|
||||
signatureDataUrl: signatureDataUrl!,
|
||||
signatureDataUrl: signatureDataUrl,
|
||||
signatureText: null,
|
||||
}
|
||||
: {
|
||||
@ -153,7 +154,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
email,
|
||||
planId,
|
||||
signatureDataUrl: null,
|
||||
signatureText: signatureText!,
|
||||
signatureText: signatureText ?? '',
|
||||
};
|
||||
|
||||
const [result] = await Promise.all([claimPlan(claimPlanInput), delay]);
|
||||
|
||||
@ -43,7 +43,6 @@ export default async function handler(
|
||||
|
||||
if (user && user.Subscription.length > 0) {
|
||||
return res.status(200).json({
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
||||
});
|
||||
}
|
||||
@ -104,7 +103,6 @@ export default async function handler(
|
||||
mode: 'subscription',
|
||||
metadata,
|
||||
allow_promotion_codes: true,
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
||||
email,
|
||||
|
||||
@ -17,14 +17,13 @@ import {
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
const log = (...args: any[]) => console.log('[stripe]', ...args);
|
||||
const log = (...args: unknown[]) => console.log('[stripe]', ...args);
|
||||
|
||||
export const config = {
|
||||
api: { bodyParser: false },
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
|
||||
// return res.status(500).json({
|
||||
// success: false,
|
||||
@ -55,6 +54,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
log('event-type:', event.type);
|
||||
|
||||
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;
|
||||
|
||||
if (session.metadata?.source === 'landing') {
|
||||
|
||||
@ -4,13 +4,14 @@
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"dev": "PORT=3000 next dev",
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/ee": "*",
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/tailwind-config": "*",
|
||||
@ -21,6 +22,7 @@
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.214.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.12",
|
||||
@ -36,12 +38,14 @@
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"sharp": "0.32.5",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"typescript": "5.1.6",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/formidable": "^2.0.6",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/node": "20.1.0",
|
||||
"@types/react": "18.2.18",
|
||||
"@types/react-dom": "18.2.7"
|
||||
|
||||
@ -5,6 +5,7 @@ import { Clock, File, FileCheck } from 'lucide-react';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -21,15 +22,33 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
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() {
|
||||
const session = await getRequiredServerComponentSession();
|
||||
const user = await getRequiredServerComponentSession();
|
||||
|
||||
const [stats, results] = await Promise.all([
|
||||
getStats({
|
||||
userId: session.id,
|
||||
user,
|
||||
}),
|
||||
findDocuments({
|
||||
userId: session.id,
|
||||
userId: user.id,
|
||||
perPage: 10,
|
||||
}),
|
||||
]);
|
||||
@ -39,15 +58,11 @@ export default async function DashboardPage() {
|
||||
<h1 className="text-4xl font-semibold">Dashboard</h1>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Link href={'/documents?status=COMPLETED'} passHref>
|
||||
<CardMetric icon={FileCheck} title="Completed" value={stats.COMPLETED} />
|
||||
</Link>
|
||||
<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>
|
||||
{CARD_DATA.map((card) => (
|
||||
<Link key={card.status} href={`/documents?status=${card.status}`}>
|
||||
<CardMetric icon={card.icon} title={card.title} value={stats[card.status]} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
|
||||
@ -13,6 +13,11 @@ import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/ad
|
||||
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
import {
|
||||
DocumentFlowFormContainer,
|
||||
DocumentFlowFormContainerHeader,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@ -28,6 +33,8 @@ export type EditDocumentFormProps = {
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
||||
|
||||
export const EditDocumentForm = ({
|
||||
className,
|
||||
document,
|
||||
@ -38,29 +45,34 @@ export const EditDocumentForm = ({
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers');
|
||||
const [step, setStep] = useState<EditDocumentStep>('signers');
|
||||
|
||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||
|
||||
const onNextStep = () => {
|
||||
if (step === 'signers') {
|
||||
setStep('fields');
|
||||
}
|
||||
|
||||
if (step === 'fields') {
|
||||
setStep('subject');
|
||||
}
|
||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||
signers: {
|
||||
title: 'Add Signers',
|
||||
description: 'Add the people who will sign the document.',
|
||||
stepIndex: 1,
|
||||
onSubmit: () => onAddSignersFormSubmit,
|
||||
},
|
||||
fields: {
|
||||
title: 'Add Fields',
|
||||
description: 'Add all relevant fields for each recipient.',
|
||||
stepIndex: 2,
|
||||
onBackStep: () => setStep('signers'),
|
||||
onSubmit: () => onAddFieldsFormSubmit,
|
||||
},
|
||||
subject: {
|
||||
title: 'Add Subject',
|
||||
description: 'Add the subject and message you wish to send to signers.',
|
||||
stepIndex: 3,
|
||||
onBackStep: () => setStep('fields'),
|
||||
onSubmit: () => onAddSubjectFormSubmit,
|
||||
},
|
||||
};
|
||||
|
||||
const onPreviousStep = () => {
|
||||
if (step === 'fields') {
|
||||
setStep('signers');
|
||||
}
|
||||
|
||||
if (step === 'subject') {
|
||||
setStep('fields');
|
||||
}
|
||||
};
|
||||
const currentDocumentFlow = documentFlow[step];
|
||||
|
||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||
try {
|
||||
@ -72,7 +84,7 @@ export const EditDocumentForm = ({
|
||||
|
||||
router.refresh();
|
||||
|
||||
onNextStep();
|
||||
setStep('fields');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@ -94,7 +106,7 @@ export const EditDocumentForm = ({
|
||||
|
||||
router.refresh();
|
||||
|
||||
onNextStep();
|
||||
setStep('subject');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@ -118,9 +130,13 @@ export const EditDocumentForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
toast({
|
||||
title: 'Document sent',
|
||||
description: 'Your document has been sent successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onNextStep();
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@ -144,38 +160,43 @@ export const EditDocumentForm = ({
|
||||
</Card>
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
{step === 'signers' && (
|
||||
<AddSignersFormPartial
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
document={document}
|
||||
onContinue={onNextStep}
|
||||
onGoBack={onPreviousStep}
|
||||
onSubmit={onAddSignersFormSubmit}
|
||||
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={currentDocumentFlow.title}
|
||||
description={currentDocumentFlow.description}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'fields' && (
|
||||
<AddFieldsFormPartial
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
document={document}
|
||||
onContinue={onNextStep}
|
||||
onGoBack={onPreviousStep}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
/>
|
||||
)}
|
||||
{step === 'signers' && (
|
||||
<AddSignersFormPartial
|
||||
documentFlow={documentFlow.signers}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
onSubmit={onAddSignersFormSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'subject' && (
|
||||
<AddSubjectFormPartial
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
document={document}
|
||||
onContinue={onNextStep}
|
||||
onGoBack={onPreviousStep}
|
||||
onSubmit={onAddSubjectFormSubmit}
|
||||
/>
|
||||
)}
|
||||
{step === 'fields' && (
|
||||
<AddFieldsFormPartial
|
||||
documentFlow={documentFlow.fields}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'subject' && (
|
||||
<AddSubjectFormPartial
|
||||
documentFlow={documentFlow.subject}
|
||||
document={document}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
onSubmit={onAddSubjectFormSubmit}
|
||||
/>
|
||||
)}
|
||||
</DocumentFlowFormContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -9,12 +9,13 @@ export default function Loading() {
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Documents
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||
Loading Document...
|
||||
</h1>
|
||||
<div className="mt-8 grid min-h-[80vh] w-full grid-cols-12 gap-x-8">
|
||||
<div className="mt-8 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
||||
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||
<div className="flex min-h-[80vh] flex-col items-center justify-center">
|
||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||
|
||||
@ -7,8 +7,11 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
@ -69,18 +72,28 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<Users2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<span>{recipients.length} Recipient(s)</span>
|
||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||
<span>{recipients.length} Recipient(s)</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
document={document}
|
||||
user={session}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
/>
|
||||
{document.status !== InternalDocumentStatus.COMPLETED && (
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
document={document}
|
||||
user={session}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
/>
|
||||
)}
|
||||
|
||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||
<div className="mx-auto mt-12 max-w-2xl">
|
||||
<LazyPDFViewer document={`data:application/pdf;base64,${document.document}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Edit, Pencil, Share } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type DataTableActionButtonProps = {
|
||||
row: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
};
|
||||
|
||||
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||
|
||||
const isOwner = row.User.id === session.user.id;
|
||||
const isRecipient = !!recipient;
|
||||
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||
const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
|
||||
return match({
|
||||
isOwner,
|
||||
isRecipient,
|
||||
isDraft,
|
||||
isPending,
|
||||
isComplete,
|
||||
isSigned,
|
||||
})
|
||||
.with({ isOwner: true, isDraft: true }, () => (
|
||||
<Button className="w-24" asChild>
|
||||
<Link href={`/documents/${row.id}`}>
|
||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
))
|
||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-24" asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||
Sign
|
||||
</Link>
|
||||
</Button>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Button className="w-24" disabled>
|
||||
<Share className="-ml-1 mr-2 h-4 w-4" />
|
||||
Share
|
||||
</Button>
|
||||
));
|
||||
};
|
||||
@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
History,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Share,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
row: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
};
|
||||
|
||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||
|
||||
const isOwner = row.User.id === session.user.id;
|
||||
// const isRecipient = !!recipient;
|
||||
// const isDraft = row.status === DocumentStatus.DRAFT;
|
||||
// const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
|
||||
const onDownloadClick = () => {
|
||||
let decodedDocument = row.document;
|
||||
|
||||
try {
|
||||
decodedDocument = atob(decodedDocument);
|
||||
} catch (err) {
|
||||
// We're just going to ignore this error and try to download the document
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
|
||||
|
||||
const blob = new Blob([documentBytes], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = row.title || 'document.pdf';
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontal className="h-5 w-5 text-gray-500" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled={!recipient} asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Sign
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner} asChild>
|
||||
<Link href={`/documents/${row.id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Void
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Resend
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled>
|
||||
<Share className="mr-2 h-4 w-4" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -8,7 +8,7 @@ import { Loader } from 'lucide-react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient';
|
||||
import { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
|
||||
@ -16,8 +16,16 @@ import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-a
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { DataTableActionButton } from './data-table-action-button';
|
||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||
|
||||
export type DocumentsDataTableProps = {
|
||||
results: FindResultSet<DocumentWithReciepient>;
|
||||
results: FindResultSet<
|
||||
Document & {
|
||||
Recipient: Recipient[];
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
@ -45,7 +53,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
{
|
||||
header: 'Title',
|
||||
cell: ({ row }) => (
|
||||
<Link href={`/documents/${row.original.id}`} className="font-medium hover:underline">
|
||||
<Link
|
||||
href={`/documents/${row.original.id}`}
|
||||
title={row.original.title}
|
||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||
>
|
||||
{row.original.title}
|
||||
</Link>
|
||||
),
|
||||
@ -67,6 +79,15 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
accessorKey: 'created',
|
||||
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
|
||||
},
|
||||
{
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DataTableActionButton row={row.original} />
|
||||
<DataTableActionDropdown row={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
|
||||
@ -3,8 +3,8 @@ import Link from 'next/link';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { isDocumentStatus } from '@documenso/lib/types/is-document-status';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
@ -16,7 +16,7 @@ import { DocumentsDataTable } from './data-table';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
searchParams?: {
|
||||
status?: InternalDocumentStatus | 'ALL';
|
||||
status?: ExtendedDocumentStatus;
|
||||
period?: PeriodSelectorValue;
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
@ -24,22 +24,20 @@ export type DocumentsPageProps = {
|
||||
};
|
||||
|
||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||
const session = await getRequiredServerComponentSession();
|
||||
const user = await getRequiredServerComponentSession();
|
||||
|
||||
const stats = await getStats({
|
||||
userId: session.id,
|
||||
user,
|
||||
});
|
||||
|
||||
const status = isDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||
// const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 20;
|
||||
|
||||
const shouldDefaultToPending = status === 'ALL' && stats.PENDING > 0;
|
||||
|
||||
const results = await findDocuments({
|
||||
userId: session.id,
|
||||
status: status === 'ALL' ? undefined : status,
|
||||
userId: user.id,
|
||||
status,
|
||||
orderBy: {
|
||||
column: 'created',
|
||||
direction: 'desc',
|
||||
@ -57,10 +55,6 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
if (value === 'ALL') {
|
||||
params.delete('status');
|
||||
}
|
||||
|
||||
return `/documents?${params.toString()}`;
|
||||
};
|
||||
|
||||
@ -71,41 +65,27 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
<h1 className="mt-12 text-4xl font-semibold">Documents</h1>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
|
||||
<Tabs defaultValue={shouldDefaultToPending ? InternalDocumentStatus.PENDING : status}>
|
||||
<Tabs defaultValue={status} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.PENDING} asChild>
|
||||
<Link href={getTabHref(InternalDocumentStatus.PENDING)}>
|
||||
<DocumentStatus status={InternalDocumentStatus.PENDING} />
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
].map((value) => (
|
||||
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
|
||||
<Link href={getTabHref(value)} scroll={false}>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||
{Math.min(stats.PENDING, 99)}
|
||||
</span>
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.COMPLETED} asChild>
|
||||
<Link href={getTabHref(InternalDocumentStatus.COMPLETED)}>
|
||||
<DocumentStatus status={InternalDocumentStatus.COMPLETED} />
|
||||
|
||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||
{Math.min(stats.COMPLETED, 99)}
|
||||
</span>
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.DRAFT} asChild>
|
||||
<Link href={getTabHref(InternalDocumentStatus.DRAFT)}>
|
||||
<DocumentStatus status={InternalDocumentStatus.DRAFT} />
|
||||
|
||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||
{Math.min(stats.DRAFT, 99)}
|
||||
</span>
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger className="min-w-[60px]" value="ALL" asChild>
|
||||
<Link href={getTabHref('ALL')}>All</Link>
|
||||
</TabsTrigger>
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||
{Math.min(stats[value], 99)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
|
||||
|
||||
export default async function BillingSettingsPage() {
|
||||
@ -15,17 +21,55 @@ export default async function BillingSettingsPage() {
|
||||
redirect('/settings/profile');
|
||||
}
|
||||
|
||||
let subscription = await getSubscriptionByUserId({ userId: user.id });
|
||||
|
||||
// If we don't have a customer record, create one as well as an empty subscription.
|
||||
if (!subscription?.customerId) {
|
||||
subscription = await createCustomer({ user });
|
||||
}
|
||||
|
||||
let billingPortalUrl = '';
|
||||
|
||||
if (subscription?.customerId) {
|
||||
billingPortalUrl = await getPortalSession({
|
||||
customerId: subscription.customerId,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Billing</h3>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Here you can update and manage your subscription.
|
||||
Your subscription is{' '}
|
||||
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
|
||||
{subscription?.periodEnd && (
|
||||
<>
|
||||
{' '}
|
||||
Your next payment is due on{' '}
|
||||
<span className="font-semibold">
|
||||
<LocaleDate date={subscription.periodEnd} />
|
||||
</span>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<PasswordForm user={user} className="max-w-xl" />
|
||||
{billingPortalUrl && (
|
||||
<Button asChild>
|
||||
<Link href={billingPortalUrl}>Manage Subscription</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!billingPortalUrl && (
|
||||
<p className="max-w-[60ch] text-base text-slate-500">
|
||||
You do not currently have a customer record, this should not happen. Please contact
|
||||
support for assistance.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -50,7 +50,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
return (
|
||||
<form
|
||||
className={cn(
|
||||
'dark:bg-background border-border bg-widget sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border px-4 py-6',
|
||||
'dark:bg-background border-border bg-widget sticky top-20 flex h-full max-h-[80rem] flex-col rounded-xl border px-4 py-6',
|
||||
)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
@ -64,7 +64,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
<div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">
|
||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">Full Name</Label>
|
||||
@ -87,9 +87,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
className="h-44 w-full"
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
console.log({
|
||||
signpadValue: value,
|
||||
});
|
||||
setSignature(value);
|
||||
}}
|
||||
/>
|
||||
@ -98,10 +95,10 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
>
|
||||
@ -109,8 +106,8 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
size="lg"
|
||||
disabled={!isComplete || isSubmitting}
|
||||
>
|
||||
|
||||
@ -149,7 +149,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
disabled={!localFullName}
|
||||
onClick={() => {
|
||||
setShowFullNameModal(false);
|
||||
onSign('local');
|
||||
void onSign('local');
|
||||
}}
|
||||
>
|
||||
Sign
|
||||
|
||||
@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
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 { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
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 { DateField } from './date-field';
|
||||
import { EmailField } from './email-field';
|
||||
import { SigningForm } from './form';
|
||||
import { NameField } from './name-field';
|
||||
import { SigningProvider } from './provider';
|
||||
@ -42,10 +44,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const user = await getServerComponentSession();
|
||||
|
||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||
|
||||
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">
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
@ -57,7 +61,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-12 gap-8">
|
||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||
<Card
|
||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||
gradient
|
||||
@ -84,6 +88,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
.with(FieldType.DATE, () => (
|
||||
<DateField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.EMAIL, () => (
|
||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.otherwise(() => null),
|
||||
)}
|
||||
</ElementVisible>
|
||||
|
||||
@ -28,9 +28,9 @@ export const useRequiredSigningContext = () => {
|
||||
};
|
||||
|
||||
export interface SigningProviderProps {
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
signature?: string;
|
||||
fullName?: string | null;
|
||||
email?: string | null;
|
||||
signature?: string | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@ -63,11 +63,6 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
|
||||
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
||||
try {
|
||||
console.log({
|
||||
providedSignature,
|
||||
localSignature,
|
||||
});
|
||||
|
||||
if (!providedSignature && !localSignature) {
|
||||
setShowSignatureModal(true);
|
||||
return;
|
||||
@ -141,6 +136,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
|
||||
{state === 'signed-text' && (
|
||||
<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}
|
||||
</p>
|
||||
)}
|
||||
@ -182,7 +178,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
disabled={!localSignature}
|
||||
onClick={() => {
|
||||
setShowSignatureModal(false);
|
||||
onSign('local');
|
||||
void onSign('local');
|
||||
}}
|
||||
>
|
||||
Sign
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 337 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 394 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 20 MiB After Width: | Height: | Size: 14 MiB |
@ -46,7 +46,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr
|
||||
className={`
|
||||
${zIndexClass}
|
||||
${firstClass}
|
||||
h-10 w-10 border-2 border-solid border-white`}
|
||||
dark:border-border h-10 w-10 border-2 border-solid border-white`}
|
||||
>
|
||||
<AvatarFallback className={classes}>{fallbackText ?? 'UK'}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@ -11,7 +11,17 @@ import {
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
import { StackAvatars } from './stack-avatars';
|
||||
|
||||
export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[] }) => {
|
||||
export type StackAvatarsWithTooltipProps = {
|
||||
recipients: Recipient[];
|
||||
position?: 'top' | 'bottom';
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const StackAvatarsWithTooltip = ({
|
||||
recipients,
|
||||
position,
|
||||
children,
|
||||
}: StackAvatarsWithTooltipProps) => {
|
||||
const waitingRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === 'waiting',
|
||||
);
|
||||
@ -32,9 +42,10 @@ export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex cursor-pointer">
|
||||
<StackAvatars recipients={recipients} />
|
||||
{children || <StackAvatars recipients={recipients} />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
<TooltipContent side={position}>
|
||||
<div className="flex flex-col gap-y-5 p-1">
|
||||
{completedRecipients.length > 0 && (
|
||||
<div>
|
||||
|
||||
@ -7,13 +7,15 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
// const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className={cn('ml-8 hidden flex-1 gap-x-6 md:flex', className)} {...props}>
|
||||
{/* No Nav tabs while there is only one main page */}
|
||||
{/* We have no other subpaths rn */}
|
||||
{/* <Link
|
||||
href="/documents"
|
||||
className={cn(
|
||||
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 ',
|
||||
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||
{
|
||||
'text-foreground': pathname?.startsWith('/documents'),
|
||||
},
|
||||
|
||||
@ -4,11 +4,8 @@ import { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Menu } from 'lucide-react';
|
||||
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
|
||||
@ -23,7 +20,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-40 flex h-16 w-full items-center border-b backdrop-blur',
|
||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b backdrop-blur',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@ -41,9 +38,9 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
<div className="flex gap-x-4">
|
||||
<ProfileDropdown user={user} />
|
||||
|
||||
<Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
||||
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
||||
<Menu className="h-6 w-6" />
|
||||
</Button>
|
||||
</Button> */}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -118,7 +118,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
signOut({
|
||||
void signOut({
|
||||
callbackUrl: '/',
|
||||
})
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ export const PeriodSelector = () => {
|
||||
params.delete('period');
|
||||
}
|
||||
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@ -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,148 +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 = () => {
|
||||
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,400 +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', '');
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -3,16 +3,17 @@ import { HTMLAttributes } from 'react';
|
||||
import { CheckCircle2, Clock, File } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
type FriendlyStatus = {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
icon?: LucideIcon;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = {
|
||||
const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
|
||||
PENDING: {
|
||||
label: 'Pending',
|
||||
icon: Clock,
|
||||
@ -28,10 +29,19 @@ const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = {
|
||||
icon: File,
|
||||
color: 'text-yellow-500',
|
||||
},
|
||||
INBOX: {
|
||||
label: 'Inbox',
|
||||
icon: SignatureIcon,
|
||||
color: 'text-muted-foreground',
|
||||
},
|
||||
ALL: {
|
||||
label: 'All',
|
||||
color: 'text-muted-foreground',
|
||||
},
|
||||
};
|
||||
|
||||
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
status: InternalDocumentStatus;
|
||||
status: ExtendedDocumentStatus;
|
||||
inheritColor?: boolean;
|
||||
};
|
||||
|
||||
@ -45,11 +55,13 @@ export const DocumentStatus = ({
|
||||
|
||||
return (
|
||||
<span className={cn('flex items-center', className)} {...props}>
|
||||
<Icon
|
||||
className={cn('mr-2 inline-block h-4 w-4', {
|
||||
[color]: !inheritColor,
|
||||
})}
|
||||
/>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cn('mr-2 inline-block h-4 w-4', {
|
||||
[color]: !inheritColor,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
@ -18,8 +18,8 @@ import { FormErrorMessage } from '../form/form-error-message';
|
||||
|
||||
export const ZPasswordFormSchema = z
|
||||
.object({
|
||||
password: z.string().min(6),
|
||||
repeatedPassword: z.string().min(6),
|
||||
password: z.string().min(6).max(72),
|
||||
repeatedPassword: z.string().min(6).max(72),
|
||||
})
|
||||
.refine((data) => data.password === data.repeatedPassword, {
|
||||
message: 'Passwords do not match',
|
||||
@ -39,6 +39,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TPasswordFormSchema>({
|
||||
values: {
|
||||
@ -56,6 +57,8 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
password,
|
||||
});
|
||||
|
||||
reset();
|
||||
|
||||
toast({
|
||||
title: 'Password updated',
|
||||
description: 'Your password has been updated successfully.',
|
||||
@ -73,7 +76,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
title: 'An unknown error occurred',
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to sign you In. Please try again later.',
|
||||
'We encountered an unknown error while attempting to update your password. Please try again later.',
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -92,6 +95,9 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('password')}
|
||||
/>
|
||||
@ -107,6 +113,9 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
<Input
|
||||
id="repeated-password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('repeatedPassword')}
|
||||
/>
|
||||
|
||||
@ -21,7 +21,7 @@ import { FormErrorMessage } from '../form/form-error-message';
|
||||
|
||||
export const ZProfileFormSchema = z.object({
|
||||
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>;
|
||||
@ -118,10 +118,12 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
render={({ field: { onChange } }) => (
|
||||
<SignaturePad
|
||||
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 ?? '')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormErrorMessage className="mt-1.5" error={errors.signature} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZSignInFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
password: z.string().min(1),
|
||||
password: z.string().min(6).max(72),
|
||||
});
|
||||
|
||||
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
||||
@ -76,10 +76,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
return (
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit(onFormSubmit)();
|
||||
}}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-slate-500">
|
||||
@ -99,6 +96,9 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="current-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
@ -3,13 +3,14 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader } from 'lucide-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 { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
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 { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
@ -18,7 +19,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
export const ZSignUpFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email().min(1),
|
||||
password: z.string().min(1),
|
||||
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>;
|
||||
@ -31,6 +33,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
@ -39,15 +42,16 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
signature: '',
|
||||
},
|
||||
resolver: zodResolver(ZSignUpFormSchema),
|
||||
});
|
||||
|
||||
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ name, email, password }: TSignUpFormSchema) => {
|
||||
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
|
||||
try {
|
||||
await signup({ name, email, password });
|
||||
await signup({ name, email, password, signature });
|
||||
|
||||
await signIn('credentials', {
|
||||
email,
|
||||
@ -105,6 +109,9 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('password')}
|
||||
/>
|
||||
@ -116,8 +123,19 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
</Label>
|
||||
|
||||
<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>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.signature} />
|
||||
</div>
|
||||
|
||||
<Button size="lg" disabled={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
||||
|
||||
@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
import { TFeatureFlagValue, ZFeatureFlagValueSchema } from '~/providers/feature-flag';
|
||||
import { TFeatureFlagValue, ZFeatureFlagValueSchema } from '~/providers/feature-flag.types';
|
||||
|
||||
/**
|
||||
* Evaluate whether a flag is enabled for the current user.
|
||||
@ -32,7 +32,7 @@ export const getFlag = async (
|
||||
revalidate: 60,
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZFeatureFlagValueSchema.parse(res))
|
||||
.catch(() => false);
|
||||
|
||||
@ -64,7 +64,7 @@ export const getAllFlags = async (
|
||||
revalidate: 60,
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
|
||||
.catch(() => LOCAL_FEATURE_FLAGS);
|
||||
};
|
||||
|
||||
@ -11,6 +11,6 @@ export default function PostHogServerClient() {
|
||||
|
||||
return new PostHog(postHogConfig.key, {
|
||||
host: postHogConfig.host,
|
||||
fetch: (...args) => fetch(...args),
|
||||
fetch: async (...args) => fetch(...args),
|
||||
});
|
||||
}
|
||||
|
||||
18
apps/web/src/hooks/use-debounced-value.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delay: number) {
|
||||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
|
||||
export default async function middleware(req: NextRequest) {
|
||||
if (req.nextUrl.pathname === '/') {
|
||||
const redirectUrl = new URL('/documents', req.url);
|
||||
@ -7,19 +9,15 @@ export default async function middleware(req: NextRequest) {
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
}
|
||||
|
||||
// if (req.nextUrl.pathname.startsWith('/dashboard')) {
|
||||
// const token = await getToken({ req });
|
||||
if (req.nextUrl.pathname.startsWith('/signin')) {
|
||||
const token = await getToken({ req });
|
||||
|
||||
// console.log('token', token);
|
||||
if (token) {
|
||||
const redirectUrl = new URL('/documents', req.url);
|
||||
|
||||
// if (!token) {
|
||||
// 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.redirect(redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
@ -43,7 +43,6 @@ export default async function handler(
|
||||
|
||||
if (user && user.Subscription.length > 0) {
|
||||
return res.status(200).json({
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
||||
});
|
||||
}
|
||||
@ -104,7 +103,6 @@ export default async function handler(
|
||||
mode: 'subscription',
|
||||
metadata,
|
||||
allow_promotion_codes: true,
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
||||
email,
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import formidable from 'formidable';
|
||||
import { type File } from 'formidable';
|
||||
import formidable, { type File } from 'formidable';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
import { getServerSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
|
||||
@ -17,14 +17,13 @@ import {
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
const log = (...args: any[]) => console.log('[stripe]', ...args);
|
||||
const log = (...args: unknown[]) => console.log('[stripe]', ...args);
|
||||
|
||||
export const config = {
|
||||
api: { bodyParser: false },
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
|
||||
// return res.status(500).json({
|
||||
// success: false,
|
||||
@ -55,6 +54,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
log('event-type:', event.type);
|
||||
|
||||
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;
|
||||
|
||||
if (session.metadata?.source === 'landing') {
|
||||
|
||||
@ -4,7 +4,7 @@ import { appRouter } from '@documenso/trpc/server/router';
|
||||
|
||||
export default trpcNext.createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext: ({ req, res }) => createTrpcContext({ req, res }),
|
||||
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
||||
});
|
||||
|
||||
// export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
FEATURE_FLAG_POLL_INTERVAL,
|
||||
LOCAL_FEATURE_FLAGS,
|
||||
@ -12,14 +10,7 @@ import {
|
||||
|
||||
import { getAllFlags } from '~/helpers/get-feature-flag';
|
||||
|
||||
export const ZFeatureFlagValueSchema = z.union([
|
||||
z.boolean(),
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.undefined(),
|
||||
]);
|
||||
|
||||
export type TFeatureFlagValue = z.infer<typeof ZFeatureFlagValueSchema>;
|
||||
import { TFeatureFlagValue } from './feature-flag.types';
|
||||
|
||||
export type FeatureFlagContextValue = {
|
||||
getFlag: (_key: string) => TFeatureFlagValue;
|
||||
@ -67,7 +58,7 @@ export function FeatureFlagProvider({
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (document.hasFocus()) {
|
||||
getAllFlags().then((newFlags) => setFlags(newFlags));
|
||||
void getAllFlags().then((newFlags) => setFlags(newFlags));
|
||||
}
|
||||
}, FEATURE_FLAG_POLL_INTERVAL);
|
||||
|
||||
@ -84,7 +75,7 @@ export function FeatureFlagProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
const onFocus = () => getAllFlags().then((newFlags) => setFlags(newFlags));
|
||||
const onFocus = () => void getAllFlags().then((newFlags) => setFlags(newFlags));
|
||||
|
||||
window.addEventListener('focus', onFocus);
|
||||
|
||||
|
||||
10
apps/web/src/providers/feature-flag.types.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZFeatureFlagValueSchema = z.union([
|
||||
z.boolean(),
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.undefined(),
|
||||
]);
|
||||
|
||||
export type TFeatureFlagValue = z.infer<typeof ZFeatureFlagValueSchema>;
|
||||
@ -7,5 +7,6 @@ module.exports = {
|
||||
content: [
|
||||
...baseConfig.content,
|
||||
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
|
||||
`${path.join(require.resolve('@documenso/email'), '..')}/**/*.{ts,tsx}`,
|
||||
],
|
||||
};
|
||||
|
||||
1047
package-lock.json
generated
@ -18,14 +18,14 @@
|
||||
"@commitlint/config-conventional": "^17.7.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"dotenv-cli": "^7.2.1",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^8.0.0",
|
||||
"lint-staged": "^14.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"turbo": "^1.9.3"
|
||||
},
|
||||
"name": "documenso.next",
|
||||
"name": "@documenso/root",
|
||||
"packageManager": "npm@8.19.2",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
|
||||
40
packages/ee/LICENSE
Normal file
@ -0,0 +1,40 @@
|
||||
The Documenso Commercial License (the “Commercial License”)
|
||||
Copyright (c) 2023 Documenso, Inc
|
||||
|
||||
With regard to the Documenso Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, an agreement governing
|
||||
the use of the Software, as mutually agreed by you and Documenso, Inc ("Documenso"),
|
||||
and otherwise have a valid Documenso Enterprise Edition subscription ("Commercial Subscription").
|
||||
Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software.
|
||||
You agree that Documenso and/or its licensors (as applicable) retain all right, title and interest in
|
||||
and to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Commercial Subscription for the correct number of hosts.
|
||||
Notwithstanding the foregoing, you may copy and modify the Software for development
|
||||
and testing purposes, without requiring a subscription. You agree that Documenso and/or
|
||||
its licensors (as applicable) retain all right, title and interest in and to all such
|
||||
modifications. You are not granted any other rights beyond what is expressly stated herein.
|
||||
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
||||
and/or sell the Software.
|
||||
|
||||
This Commercial License applies only to the part of this Software that is not distributed under
|
||||
the AGPLv3 license. Any part of this Software distributed under the MIT license or which
|
||||
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
|
||||
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
|
||||
in part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall
|
||||
be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Documenso Software, those
|
||||
components are licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
1
packages/ee/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {};
|
||||
17
packages/ee/package.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@documenso/ee",
|
||||
"version": "1.0.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"license": "COMMERCIAL",
|
||||
"files": [
|
||||
"client-only/",
|
||||
"server-only/",
|
||||
"universal/"
|
||||
],
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*"
|
||||
}
|
||||
}
|
||||
31
packages/ee/server-only/stripe/create-customer.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
export type CreateCustomerOptions = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const createCustomer = async ({ user }: CreateCustomerOptions) => {
|
||||
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
||||
|
||||
if (existingSubscription) {
|
||||
throw new Error('User already has a subscription');
|
||||
}
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
name: user.name ?? undefined,
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return await prisma.subscription.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
customerId: customer.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
19
packages/ee/server-only/stripe/get-portal-session.ts
Normal file
@ -0,0 +1,19 @@
|
||||
'use server';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type GetPortalSessionOptions = {
|
||||
customerId: string;
|
||||
returnUrl: string;
|
||||
};
|
||||
|
||||
export const getPortalSession = async ({ customerId, returnUrl }: GetPortalSessionOptions) => {
|
||||
'use server';
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
|
||||
return session.url;
|
||||
};
|
||||
5
packages/ee/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@documenso/tsconfig/react-library.json",
|
||||
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
export interface TemplateDocumentCompletedProps {
|
||||
downloadLink: string;
|
||||
reviewLink: string;
|
||||
documentName: string;
|
||||
assetBaseUrl: string;
|
||||
}
|
||||
|
||||
export const TemplateDocumentCompleted = ({
|
||||
downloadLink,
|
||||
reviewLink,
|
||||
documentName,
|
||||
assetBaseUrl,
|
||||
}: TemplateDocumentCompletedProps) => {
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
||||
</div>
|
||||
|
||||
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
|
||||
<Img src={getAssetUrl('/static/completed.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
||||
Completed
|
||||
</Text>
|
||||
|
||||
<Text className="text-primary mb-0 text-center text-lg font-semibold">
|
||||
“{documentName}” was signed by all signers
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
Continue by downloading or reviewing the document.
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href={reviewLink}
|
||||
>
|
||||
<Img src={getAssetUrl('/static/review.png')} className="-mb-1 mr-2 inline h-5 w-5" />
|
||||
Review
|
||||
</Button>
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href={downloadLink}
|
||||
>
|
||||
<Img src={getAssetUrl('/static/download.png')} className="-mb-1 mr-2 inline h-5 w-5" />
|
||||
Download
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateDocumentCompleted;
|
||||
@ -0,0 +1,59 @@
|
||||
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
export interface TemplateDocumentInviteProps {
|
||||
inviterName: string;
|
||||
inviterEmail: string;
|
||||
documentName: string;
|
||||
signDocumentLink: string;
|
||||
assetBaseUrl: string;
|
||||
}
|
||||
|
||||
export const TemplateDocumentInvite = ({
|
||||
inviterName,
|
||||
documentName,
|
||||
signDocumentLink,
|
||||
assetBaseUrl,
|
||||
}: TemplateDocumentInviteProps) => {
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Section className="mt-4 flex-row items-center justify-center">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
||||
</div>
|
||||
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
{inviterName} has invited you to sign "{documentName}"
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
Continue by signing the document.
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={signDocumentLink}
|
||||
>
|
||||
Sign Document
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateDocumentInvite;
|
||||
@ -0,0 +1,52 @@
|
||||
import { Img, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
export interface TemplateDocumentPendingProps {
|
||||
documentName: string;
|
||||
assetBaseUrl: string;
|
||||
}
|
||||
|
||||
export const TemplateDocumentPending = ({
|
||||
documentName,
|
||||
assetBaseUrl,
|
||||
}: TemplateDocumentPendingProps) => {
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
||||
</div>
|
||||
|
||||
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-blue-500">
|
||||
<Img src={getAssetUrl('/static/clock.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
||||
Waiting for others
|
||||
</Text>
|
||||
|
||||
<Text className="text-primary mb-0 text-center text-lg font-semibold">
|
||||
“{documentName}” has been signed
|
||||
</Text>
|
||||
|
||||
<Text className="mx-auto mb-6 mt-1 max-w-[80%] text-center text-base text-slate-400">
|
||||
We're still waiting for other signers to sign this document.
|
||||
<br />
|
||||
We'll notify you as soon as it's ready.
|
||||
</Text>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateDocumentPending;
|
||||
22
packages/email/template-components/template-footer.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Link, Section, Text } from '@react-email/components';
|
||||
|
||||
export const TemplateFooter = () => {
|
||||
return (
|
||||
<Section>
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
This document was sent using{' '}
|
||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
||||
Documenso.
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
<Text className="my-8 text-sm text-slate-400">
|
||||
Documenso
|
||||
<br />
|
||||
2261 Market Street, #5211, San Francisco, CA 94114, USA
|
||||
</Text>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateFooter;
|
||||
@ -1,25 +1,23 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
interface DocumentCompletedEmailTemplateProps {
|
||||
downloadLink?: string;
|
||||
reviewLink?: string;
|
||||
documentName?: string;
|
||||
assetBaseUrl?: string;
|
||||
}
|
||||
import {
|
||||
TemplateDocumentCompleted,
|
||||
TemplateDocumentCompletedProps,
|
||||
} from '../template-components/template-document-completed';
|
||||
import TemplateFooter from '../template-components/template-footer';
|
||||
|
||||
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps>;
|
||||
|
||||
export const DocumentCompletedEmailTemplate = ({
|
||||
downloadLink = 'https://documenso.com',
|
||||
@ -50,74 +48,23 @@ export const DocumentCompletedEmailTemplate = ({
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="h-6" />
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
|
||||
<Section className="mt-4 flex-row items-center justify-center">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Img
|
||||
className="h-42"
|
||||
src={getAssetUrl('/static/document.png')}
|
||||
alt="Documenso"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
|
||||
<Img
|
||||
src={getAssetUrl('/static/completed.png')}
|
||||
className="-mb-0.5 mr-2 inline h-7 w-7"
|
||||
/>
|
||||
Completed
|
||||
</Text>
|
||||
|
||||
<Text className="text-primary mb-0 text-center text-lg font-semibold">
|
||||
“{documentName}” was signed by all signers
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
Continue by downloading or reviewing the document.
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href={reviewLink}
|
||||
>
|
||||
<Img
|
||||
src={getAssetUrl('/static/review.png')}
|
||||
className="-mb-1 mr-2 inline h-5 w-5"
|
||||
/>
|
||||
Review
|
||||
</Button>
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href={downloadLink}
|
||||
>
|
||||
<Img
|
||||
src={getAssetUrl('/static/download.png')}
|
||||
className="-mb-1 mr-2 inline h-5 w-5"
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
<TemplateDocumentCompleted
|
||||
downloadLink={downloadLink}
|
||||
reviewLink={reviewLink}
|
||||
documentName={documentName}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<Section>
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
This document was sent using{' '}
|
||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
||||
Documenso.
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
<Text className="my-8 text-sm text-slate-400">
|
||||
Documenso
|
||||
<br />
|
||||
2261 Market Street, #5211, San Francisco, CA 94114, USA
|
||||
</Text>
|
||||
</Section>
|
||||
<TemplateFooter />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
|
||||