Compare commits
149 Commits
feat/docum
...
feat/add-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87e7390976 | ||
|
|
7160e95594 | ||
|
|
859daefc83 | ||
|
|
dea7b8bd49 | ||
|
|
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 | ||
|
|
2ab796910e | ||
|
|
07102588be | ||
|
|
04f9422f24 | ||
|
|
05a7f5e178 | ||
|
|
7bae814f96 | ||
|
|
ea45e38fa0 | ||
|
|
6d9a85112f | ||
|
|
346efd19db | ||
|
|
617143a47f | ||
|
|
66f067276e | ||
|
|
fc10d0449f | ||
|
|
083f3e7108 | ||
|
|
af307a2a49 | ||
|
|
b063758ee5 | ||
|
|
4964b252e3 | ||
|
|
e468f5bbc9 | ||
|
|
c5b7b8a18a | ||
|
|
a8a1fbb829 | ||
|
|
3c2a4892e7 | ||
|
|
6d9e84d327 | ||
|
|
73b4e30c97 | ||
|
|
bd01545a70 | ||
|
|
6d360e581d | ||
|
|
ba95818da4 | ||
|
|
d0720f4c70 | ||
|
|
f60cb22f11 | ||
|
|
e0cb4314fb | ||
|
|
0571137a60 | ||
|
|
30aabf50eb | ||
|
|
8441a5eb98 | ||
|
|
259ab49bc1 | ||
|
|
2f2d5dfc0b | ||
|
|
0f27f4261b | ||
|
|
9b92cad2db | ||
|
|
ad1ff6159c | ||
|
|
f633b17f17 | ||
|
|
8fa16001e6 | ||
|
|
e111234460 | ||
|
|
034072f50e | ||
|
|
a7664d79fd | ||
|
|
45d0d3f7e8 | ||
|
|
6e62eb8d81 | ||
|
|
3b9c57fe5c | ||
|
|
90e28cd3a4 | ||
|
|
e743e56787 |
@@ -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
|
||||
@@ -52,7 +54,12 @@ NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
||||
|
||||
# [[FEATURES]]
|
||||
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false
|
||||
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
# OPTIONAL: Defines the host to use for PostHog.
|
||||
NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com"
|
||||
# OPTIONAL: Leave blank to disable billing.
|
||||
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||
|
||||
# This is only required for the marketing site
|
||||
# [[REDIS]]
|
||||
|
||||
7
.eslintignore
Normal file
@@ -0,0 +1,7 @@
|
||||
# Config files
|
||||
*.config.js
|
||||
*.config.cjs
|
||||
|
||||
# Statically hosted javascript files
|
||||
apps/*/public/*.js
|
||||
apps/*/public/*.cjs
|
||||
32
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "feat/refresh"
|
||||
labels:
|
||||
- "ci dependencies"
|
||||
- "ci"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/marketing"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "feat/refresh"
|
||||
labels:
|
||||
- "npm dependencies"
|
||||
- "frontend"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "feat/refresh"
|
||||
labels:
|
||||
- "npm dependencies"
|
||||
- "frontend"
|
||||
open-pull-requests-limit: 10
|
||||
4
.github/workflows/ci.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
pull_request:
|
||||
branches: [ "feat/refresh" ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
HUSKY: 0
|
||||
|
||||
|
||||
45
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ feat/refresh ]
|
||||
pull_request:
|
||||
branches: [ feat/refresh ]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: npm
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Documenso
|
||||
run: npm run build
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
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 }}
|
||||
10
.gitignore
vendored
@@ -37,3 +37,13 @@ yarn-error.log*
|
||||
|
||||
# contentlayer
|
||||
.contentlayer
|
||||
|
||||
# intellij
|
||||
.idea
|
||||
|
||||
# vscode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
4
.husky/commit-msg
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npm run commitlint -- $1
|
||||
0
.husky/pre-commit
Normal file → Executable file
@@ -89,6 +89,10 @@ Documenso is built using awesome open source tech including:
|
||||
- [Node SignPDF (Digital Signature)](https://github.com/vbuch/node-signpdf)
|
||||
- [React-PDF for viewing PDFs](https://github.com/wojtekmaj/react-pdf)
|
||||
- [PDF-Lib for PDF manipulation](https://github.com/Hopding/pdf-lib)
|
||||
- [Zod for schema declaration and validation](https://zod.dev/)
|
||||
- [Lucide React for icons in React app](https://lucide.dev/)
|
||||
- [Framer Motion for motion library](https://www.framer.com/motion/)
|
||||
- [Radix UI for component library](https://www.radix-ui.com/)
|
||||
- Check out `/package.json` and `/apps/web/package.json` for more
|
||||
- Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned.
|
||||
|
||||
|
||||
56
apps/marketing/content/blog/next.mdx
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Preview the next Documenso
|
||||
description: We're redesigning Documenso by making it more elegant and appropriately playful. Here's a sneak peek.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2023-08-21
|
||||
tags:
|
||||
- Design
|
||||
- Preview
|
||||
---
|
||||
|
||||
Since we launched [Documenso 0.9 on Product Hunt](https://producthunt.com/products/documenso#documenso) last May, the team's been hard at work behind the scenes to ramp up development and design to deliver an excellent next version.
|
||||
|
||||
Last week, Lucas shared the reasoning how [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
|
||||
|
||||
Today, I'm pleased to share with you a preview of the next Documenso.
|
||||
|
||||
## Preview the next Documenso
|
||||
|
||||
We redesigned the whole signing flow to make it more appealing and more convenient.
|
||||
|
||||
We improved the overall look and feel by making it more elegant and appropriately playful. Focused on the task at hand, but explicitly enjoying doing it.
|
||||
|
||||
**We call it happy minimalism.**
|
||||
|
||||
We paid particular attention to the moment of signing, which should be celebrated.
|
||||
|
||||
The image below is the final bloom of the completion celebration we added:
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/blog-fig-preview-documenso.webp"
|
||||
width="2000"
|
||||
height="1268"
|
||||
alt="Figure 1"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">"You've signed a new document."</figcaption>
|
||||
</figure>
|
||||
|
||||
## Kicking off a new phase of collaboration
|
||||
|
||||
This preview also is the kickoff for a new phase of how we collaborate with the community.
|
||||
|
||||
We recently [switched to Discord](https://documenso.com/blog/switching-from-slack-to-discord) to set up a more developer-friendly, community-driven environment, and we just released the [public roadmap](https://documen.so/launches).
|
||||
|
||||
As always, if you have any questions or feedback, please reach out. We love to hear from you.
|
||||
|
||||
Best from Hamburg,
|
||||
|
||||
Timur
|
||||
|
||||
Make sure to [star the GitHub repository](https://documen.so/github), [follow us on X](http://documen.so/twitter) and [join the Discord server](https://documen.so/discord) to keep up to date with all things Documenso.
|
||||
|
||||
We're building a beautiful, open-source alternative to DocuSign.
|
||||
42
apps/marketing/content/design-system.mdx
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: Design System
|
||||
---
|
||||
|
||||
# We're building a beautiful, open-source alternative to DocuSign
|
||||
|
||||
> Read more about our design culture here:
|
||||
>
|
||||
> [https://documenso.com/blog/design-system](https://documenso.com/blog/design-system)
|
||||
|
||||
At Documenso, we aim to be a design-driven company.
|
||||
|
||||
We believe that design isn't just about how things look, but also how they work. We want to make sure that the product is easy to use and intuitive. We also want to ensure that the website, desktop and mobile apps are consistent and look and feel like they belong together.
|
||||
|
||||
To achieve this, we've created Documenso's design system containing tokens, primitives, and components, screens, and brand assets.
|
||||
|
||||
We're open-sourcing this design system so you can see how we build the product and think about design as a whole.
|
||||
|
||||
## Check out the design system
|
||||
|
||||
<iframe
|
||||
src="https://documen.so/design-system-embed"
|
||||
className="aspect-square w-full border-none"
|
||||
frameBorder="0"
|
||||
/>
|
||||
|
||||
## Remix and Share the community version on Figma
|
||||
|
||||
<a href="documen.so/design" target="_blank">
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/designsystem.png"
|
||||
width="1260"
|
||||
height="630"
|
||||
alt="Documenso's Design System"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">
|
||||
Documenso's Design System ✨
|
||||
</figcaption>
|
||||
</figure>
|
||||
</a>
|
||||
@@ -4,10 +4,11 @@
|
||||
"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"
|
||||
"lint": "next lint",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
@@ -29,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"
|
||||
},
|
||||
|
||||
BIN
apps/marketing/public/blog/blog-fig-preview-documenso.webp
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
apps/marketing/public/logo_icon.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
56611
apps/marketing/public/pdf.worker.min.js
vendored
Normal file
@@ -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"
|
||||
|
||||
@@ -56,11 +56,11 @@ export const FUNDING_RAISED = [
|
||||
},
|
||||
{
|
||||
date: '2023-05',
|
||||
amount: 300_000,
|
||||
amount: 290_000,
|
||||
},
|
||||
{
|
||||
date: '2023-07',
|
||||
amount: 1_550_000,
|
||||
amount: 1_540_000,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,12 +3,29 @@ import { HTMLAttributes } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Github, Slack, Twitter } from 'lucide-react';
|
||||
import { Github, MessagesSquare, Twitter } from 'lucide-react';
|
||||
|
||||
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: '/design-system', text: 'Design' },
|
||||
{ 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,69 +36,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://documenso.slack.com"
|
||||
target="_blank"
|
||||
className="hover:text-[#6D6D6D]"
|
||||
>
|
||||
<Slack 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://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 className="grid max-w-xs flex-1 grid-cols-2 gap-x-4 gap-y-2">
|
||||
{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">
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useState } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { HamburgerMenu } from './mobile-hamburger';
|
||||
import { MobileNavigation } from './mobile-navigation';
|
||||
|
||||
export type HeaderProps = HTMLAttributes<HTMLElement>;
|
||||
|
||||
export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||
|
||||
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 href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-x-6">
|
||||
<div className="hidden items-center gap-x-6 md:flex">
|
||||
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
||||
Pricing
|
||||
</Link>
|
||||
@@ -35,6 +42,15 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<HamburgerMenu
|
||||
onToggleMenuOpen={() => setIsHamburgerMenuOpen((v) => !v)}
|
||||
isMenuOpen={isHamburgerMenuOpen}
|
||||
/>
|
||||
<MobileNavigation
|
||||
isMenuOpen={isHamburgerMenuOpen}
|
||||
onMenuOpenChange={setIsHamburgerMenuOpen}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { Menu, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export interface HamburgerMenuProps {
|
||||
isMenuOpen: boolean;
|
||||
onToggleMenuOpen?: () => void;
|
||||
}
|
||||
|
||||
export const HamburgerMenu = ({ isMenuOpen, onToggleMenuOpen }: HamburgerMenuProps) => {
|
||||
return (
|
||||
<div className="flex md:hidden">
|
||||
<Button variant="outline" className="z-20 w-10 p-0" onClick={onToggleMenuOpen}>
|
||||
{isMenuOpen ? <X /> : <Menu />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
121
apps/marketing/src/components/(marketing)/mobile-navigation.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { Github, MessagesSquare, Twitter } from 'lucide-react';
|
||||
|
||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||
|
||||
export type MobileNavigationProps = {
|
||||
isMenuOpen: boolean;
|
||||
onMenuOpenChange?: (_value: boolean) => void;
|
||||
};
|
||||
|
||||
export const MENU_NAVIGATION_LINKS = [
|
||||
{
|
||||
href: '/blog',
|
||||
text: 'Blog',
|
||||
},
|
||||
{
|
||||
href: '/pricing',
|
||||
text: 'Pricing',
|
||||
},
|
||||
{
|
||||
href: 'https://status.documenso.com',
|
||||
text: 'Status',
|
||||
},
|
||||
{
|
||||
href: 'mailto:support@documenso.com',
|
||||
text: 'Support',
|
||||
},
|
||||
{
|
||||
href: '/privacy',
|
||||
text: 'Privacy',
|
||||
},
|
||||
{
|
||||
href: 'https://app.documenso.com/login',
|
||||
text: 'Sign in',
|
||||
},
|
||||
];
|
||||
|
||||
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const handleMenuItemClick = () => {
|
||||
onMenuOpenChange?.(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<SheetContent className="w-full max-w-[400px]">
|
||||
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
|
||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
||||
</Link>
|
||||
|
||||
<motion.div
|
||||
className="mt-12 flex w-full flex-col items-start gap-y-4"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
transition={{
|
||||
staggerChildren: 0.2,
|
||||
}}
|
||||
>
|
||||
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
|
||||
<motion.div
|
||||
key={href}
|
||||
variants={{
|
||||
initial: {
|
||||
opacity: 0,
|
||||
x: shouldReduceMotion ? 0 : 100,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
className="text-2xl font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||
href={href}
|
||||
onClick={() => handleMenuItemClick()}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<div className="mx-auto mt-8 flex w-full flex-wrap items-center gap-x-4 gap-y-4 ">
|
||||
<Link
|
||||
href="https://twitter.com/documenso"
|
||||
target="_blank"
|
||||
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||
>
|
||||
<Twitter className="h-6 w-6" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://github.com/documenso/documenso"
|
||||
target="_blank"
|
||||
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||
>
|
||||
<Github className="h-6 w-6" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://documen.so/discord"
|
||||
target="_blank"
|
||||
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||
>
|
||||
<MessagesSquare className="h-6 w-6" />
|
||||
</Link>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -21,12 +21,12 @@ import {
|
||||
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';
|
||||
import { SignaturePad } from '../signature-pad';
|
||||
|
||||
const ZWidgetFormSchema = z
|
||||
.object({
|
||||
@@ -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]);
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
HTMLAttributes,
|
||||
MouseEvent,
|
||||
PointerEvent,
|
||||
TouchEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { StrokeOptions, getStroke } from 'perfect-freehand';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { getSvgPathFromStroke } from './helper';
|
||||
import { Point } from './point';
|
||||
|
||||
const DPI = 2;
|
||||
|
||||
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
|
||||
onChange?: (_signatureDataUrl: string | null) => void;
|
||||
};
|
||||
|
||||
export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProps) => {
|
||||
const $el = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [points, setPoints] = useState<Point[]>([]);
|
||||
|
||||
const perfectFreehandOptions = useMemo(() => {
|
||||
const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10;
|
||||
|
||||
return {
|
||||
size,
|
||||
thinning: 0.25,
|
||||
streamline: 0.5,
|
||||
smoothing: 0.5,
|
||||
end: {
|
||||
taper: size * 2,
|
||||
},
|
||||
} satisfies StrokeOptions;
|
||||
}, []);
|
||||
|
||||
const onMouseDown = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
setIsPressed(true);
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
|
||||
const newPoints = [...points, point];
|
||||
|
||||
setPoints(newPoints);
|
||||
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.save();
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
|
||||
);
|
||||
|
||||
ctx.fill(pathData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (!isPressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
|
||||
if (point.distanceTo(points[points.length - 1]) > 5) {
|
||||
const newPoints = [...points, point];
|
||||
|
||||
setPoints(newPoints);
|
||||
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.restore();
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(points, perfectFreehandOptions)),
|
||||
);
|
||||
|
||||
ctx.fill(pathData);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addPoint = true) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
setIsPressed(false);
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
|
||||
const newPoints = [...points];
|
||||
|
||||
if (addPoint) {
|
||||
newPoints.push(point);
|
||||
|
||||
setPoints(newPoints);
|
||||
}
|
||||
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.restore();
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
|
||||
);
|
||||
|
||||
ctx.fill(pathData);
|
||||
|
||||
ctx.save();
|
||||
}
|
||||
|
||||
onChange?.($el.current.toDataURL());
|
||||
}
|
||||
|
||||
setPoints([]);
|
||||
};
|
||||
|
||||
const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if ('buttons' in event && event.buttons === 1) {
|
||||
onMouseDown(event);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeave = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
onMouseUp(event, false);
|
||||
};
|
||||
|
||||
const onClearClick = () => {
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
|
||||
}
|
||||
|
||||
onChange?.(null);
|
||||
|
||||
setPoints([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if ($el.current) {
|
||||
$el.current.width = $el.current.clientWidth * DPI;
|
||||
$el.current.height = $el.current.clientHeight * DPI;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative block">
|
||||
<canvas
|
||||
ref={$el}
|
||||
className={cn('relative block', className)}
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerMove={(event) => onMouseMove(event)}
|
||||
onPointerDown={(event) => onMouseDown(event)}
|
||||
onPointerUp={(event) => onMouseUp(event)}
|
||||
onPointerLeave={(event) => onMouseLeave(event)}
|
||||
onPointerEnter={(event) => onMouseEnter(event)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<button className="rounded-full p-2 text-xs text-slate-500" onClick={() => onClearClick()}>
|
||||
Clear Signature
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
apps/marketing/src/hooks/use-window-size.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useWindowSize() {
|
||||
const [size, setSize] = useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const onResize = () => {
|
||||
setSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onResize();
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return size;
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -9,16 +9,10 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"contentlayer/generated": [
|
||||
"./.contentlayer/generated"
|
||||
]
|
||||
"~/*": ["./src/*"],
|
||||
"contentlayer/generated": ["./.contentlayer/generated"]
|
||||
},
|
||||
"types": [
|
||||
"@documenso/lib/types/next-auth.d.ts"
|
||||
],
|
||||
"types": ["@documenso/lib/types/next-auth.d.ts"],
|
||||
"strictNullChecks": true,
|
||||
"incremental": false
|
||||
},
|
||||
@@ -29,7 +23,5 @@
|
||||
".next/types/**/*.ts",
|
||||
".contentlayer/generated"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -4,12 +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"
|
||||
"lint": "next lint",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/ee": "*",
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/tailwind-config": "*",
|
||||
@@ -20,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",
|
||||
@@ -27,19 +30,22 @@
|
||||
"next-plausible": "^3.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
"posthog-js": "^1.75.3",
|
||||
"posthog-node": "^3.1.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-pdf": "^7.1.1",
|
||||
"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"
|
||||
|
||||
2
apps/web/process-env.d.ts
vendored
@@ -10,8 +10,6 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||
|
||||
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: string;
|
||||
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
|
||||
}
|
||||
|
||||
56611
apps/web/public/pdf.worker.min.js
vendored
Normal file
@@ -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">
|
||||
|
||||
@@ -5,10 +5,10 @@ import { useRouter } from 'next/navigation';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCreateDocument } from '~/api/document/create/fetcher';
|
||||
import { DocumentDropzone } from '~/components/(dashboard)/document-dropzone/document-dropzone';
|
||||
|
||||
export type UploadDocumentProps = {
|
||||
className?: string;
|
||||
|
||||
@@ -2,14 +2,28 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
||||
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';
|
||||
|
||||
import { LazyPDFViewer } from '~/components/(dashboard)/pdf-viewer/lazy-pdf-viewer';
|
||||
import { AddFieldsFormPartial } from '~/components/forms/edit-document/add-fields';
|
||||
import { AddSignersFormPartial } from '~/components/forms/edit-document/add-signers';
|
||||
import { AddSubjectFormPartial } from '~/components/forms/edit-document/add-subject';
|
||||
import { addFields } from '~/components/forms/edit-document/add-fields.action';
|
||||
import { addSigners } from '~/components/forms/edit-document/add-signers.action';
|
||||
import { completeDocument } from '~/components/forms/edit-document/add-subject.action';
|
||||
|
||||
export type EditDocumentFormProps = {
|
||||
className?: string;
|
||||
@@ -19,6 +33,8 @@ export type EditDocumentFormProps = {
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
||||
|
||||
export const EditDocumentForm = ({
|
||||
className,
|
||||
document,
|
||||
@@ -26,27 +42,109 @@ export const EditDocumentForm = ({
|
||||
fields,
|
||||
user: _user,
|
||||
}: EditDocumentFormProps) => {
|
||||
const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers');
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState<EditDocumentStep>('signers');
|
||||
|
||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||
|
||||
const onNextStep = () => {
|
||||
if (step === 'signers') {
|
||||
setStep('fields');
|
||||
}
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
if (step === 'fields') {
|
||||
setStep('subject');
|
||||
const currentDocumentFlow = documentFlow[step];
|
||||
|
||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addSigners({
|
||||
documentId: document.id,
|
||||
signers: data.signers,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
setStep('fields');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while adding signers.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onPreviousStep = () => {
|
||||
if (step === 'fields') {
|
||||
setStep('signers');
|
||||
}
|
||||
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addFields({
|
||||
documentId: document.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
if (step === 'subject') {
|
||||
setStep('fields');
|
||||
router.refresh();
|
||||
|
||||
setStep('subject');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while adding signers.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message } = data.email;
|
||||
|
||||
try {
|
||||
await completeDocument({
|
||||
documentId: document.id,
|
||||
email: {
|
||||
subject,
|
||||
message,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Document sent',
|
||||
description: 'Your document has been sent successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while sending the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,35 +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}
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { PDFViewerProps } from '~/components/(dashboard)/pdf-viewer/pdf-viewer';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { PDFViewerProps } from '@documenso/ui/primitives/pdf-viewer';
|
||||
|
||||
export type LoadablePDFCard = PDFViewerProps & {
|
||||
className?: string;
|
||||
pdfClassName?: string;
|
||||
};
|
||||
|
||||
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
||||
<Loader className="h-12 w-12 animate-spin text-slate-500" />
|
||||
|
||||
<p className="mt-4 text-slate-500">Loading document...</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
|
||||
return (
|
||||
<Card className={className} gradient {...props}>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer className={pdfClassName} {...props} />
|
||||
<LazyPDFViewer className={pdfClassName} {...props} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Plus } 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 { isDocumentStatus } from '@documenso/lib/types/is-document-status';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
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 { DocumentDropzone } from '~/components/(dashboard)/document-dropzone/document-dropzone';
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
import {
|
||||
PeriodSelectorValue,
|
||||
isPeriodSelectorValue,
|
||||
} from '~/components/(dashboard)/period-selector/types';
|
||||
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { UploadDocument } from '../dashboard/upload-document';
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
searchParams?: {
|
||||
status?: InternalDocumentStatus | 'ALL';
|
||||
status?: ExtendedDocumentStatus;
|
||||
period?: PeriodSelectorValue;
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
@@ -30,20 +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 period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||
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 results = await findDocuments({
|
||||
userId: session.id,
|
||||
status: status === 'ALL' ? undefined : status,
|
||||
userId: user.id,
|
||||
status,
|
||||
orderBy: {
|
||||
column: 'created',
|
||||
direction: 'desc',
|
||||
@@ -52,8 +46,6 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
perPage,
|
||||
});
|
||||
|
||||
const isNoResults = status === 'ALL' && period === '' && results.data.length === 0;
|
||||
|
||||
const getTabHref = (value: typeof status) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
@@ -63,73 +55,47 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
if (value === 'ALL') {
|
||||
params.delete('status');
|
||||
}
|
||||
|
||||
return `/documents?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="text-4xl font-semibold">All Documents</h1>
|
||||
<UploadDocument />
|
||||
|
||||
<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={status}>
|
||||
<Tabs defaultValue={status} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
<TabsTrigger className="min-w-[60px]" value="ALL" asChild>
|
||||
<Link href={getTabHref('ALL')}>All</Link>
|
||||
</TabsTrigger>
|
||||
{[
|
||||
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} />
|
||||
|
||||
<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={InternalDocumentStatus.PENDING} asChild>
|
||||
<Link href={getTabHref(InternalDocumentStatus.PENDING)}>
|
||||
<DocumentStatus status={InternalDocumentStatus.PENDING} />
|
||||
|
||||
<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>
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||
{Math.min(stats[value], 99)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex flex-1 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
|
||||
<Button>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Document
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{/* If we're viewing all documents for all time and there's nuffin we should should an add document component instead */}
|
||||
{isNoResults ? (
|
||||
<DocumentDropzone className="min-h-[60vh] md:min-h-[40vh]" />
|
||||
) : (
|
||||
<DocumentsDataTable results={results} />
|
||||
)}
|
||||
<DocumentsDataTable results={results} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,75 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
||||
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() {
|
||||
const user = await getRequiredServerComponentSession();
|
||||
|
||||
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
||||
|
||||
// Redirect if subscriptions are not enabled.
|
||||
if (!IS_SUBSCRIPTIONS_ENABLED) {
|
||||
if (!isBillingEnabled) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
|
||||
import { SignaturePad } from '~/components/signature-pad';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
|
||||
@@ -51,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)}
|
||||
>
|
||||
@@ -65,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>
|
||||
@@ -88,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);
|
||||
}}
|
||||
/>
|
||||
@@ -99,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"
|
||||
>
|
||||
@@ -110,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
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
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';
|
||||
@@ -9,9 +10,7 @@ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-re
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
|
||||
import { LazyPDFViewer } from '~/components/(dashboard)/pdf-viewer/lazy-pdf-viewer';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { DateField } from './date-field';
|
||||
import { SigningForm } from './form';
|
||||
@@ -58,7 +57,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
|
||||
|
||||
@@ -12,10 +12,9 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SignaturePad } from '~/components/signature-pad';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
@@ -64,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;
|
||||
@@ -142,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>
|
||||
)}
|
||||
@@ -183,7 +178,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
disabled={!localSignature}
|
||||
onClick={() => {
|
||||
setShowSignatureModal(false);
|
||||
onSign('local');
|
||||
void onSign('local');
|
||||
}}
|
||||
>
|
||||
Sign
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
@@ -5,8 +7,11 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
import { getServerComponentAllFlags } from '~/helpers/get-server-component-feature-flag';
|
||||
import { FeatureFlagProvider } from '~/providers/feature-flag';
|
||||
import { ThemeProvider } from '~/providers/next-theme';
|
||||
import { PlausibleProvider } from '~/providers/plausible';
|
||||
import { PostHogPageview } from '~/providers/posthog';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
@@ -37,7 +42,9 @@ export const metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const flags = await getServerComponentAllFlags();
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
@@ -51,15 +58,21 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</head>
|
||||
|
||||
<Suspense>
|
||||
<PostHogPageview />
|
||||
</Suspense>
|
||||
|
||||
<body>
|
||||
<PlausibleProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<TooltipProvider>
|
||||
<TrpcProvider>{children}</TrpcProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</PlausibleProvider>
|
||||
<Toaster />
|
||||
<FeatureFlagProvider initialFlags={flags}>
|
||||
<PlausibleProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<TooltipProvider>
|
||||
<TrpcProvider>{children}</TrpcProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</PlausibleProvider>
|
||||
<Toaster />
|
||||
</FeatureFlagProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
|
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>
|
||||
|
||||
@@ -2,47 +2,26 @@
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
const pathname = usePathname();
|
||||
// const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className={cn('ml-8 hidden flex-1 gap-x-6 md:flex', className)} {...props}>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
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-foreground': pathname?.startsWith('/dashboard'),
|
||||
},
|
||||
)}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
{/* 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'),
|
||||
},
|
||||
)}
|
||||
>
|
||||
Documents
|
||||
</Link>
|
||||
{/* <Link
|
||||
href="/settings/profile"
|
||||
className={cn('font-medium leading-5 text-[#A1A1AA] hover:opacity-80', {
|
||||
'text-primary-foreground': pathname?.startsWith('/settings'),
|
||||
})}
|
||||
>
|
||||
Settings
|
||||
</Link> */}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -28,11 +27,19 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
import { useFeatureFlags } from '~/providers/feature-flag';
|
||||
|
||||
export type ProfileDropdownProps = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
|
||||
const initials =
|
||||
user.name
|
||||
?.split(' ')
|
||||
@@ -40,8 +47,6 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
.slice(0, 2)
|
||||
.join('') ?? 'UK';
|
||||
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -69,7 +74,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{IS_SUBSCRIPTIONS_ENABLED && (
|
||||
{isBillingEnabled && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/billing" className="cursor-pointer">
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
@@ -113,7 +118,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
signOut({
|
||||
void signOut({
|
||||
callbackUrl: '/',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
export const LazyPDFViewer = dynamic(
|
||||
async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -7,15 +7,20 @@ import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Key, User } from 'lucide-react';
|
||||
|
||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { useFeatureFlags } from '~/providers/feature-flag';
|
||||
|
||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||
<Link href="/settings/profile">
|
||||
@@ -44,7 +49,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_SUBSCRIPTIONS_ENABLED && (
|
||||
{isBillingEnabled && (
|
||||
<Link href="/settings/billing">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -7,15 +7,20 @@ import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Key, User } from 'lucide-react';
|
||||
|
||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { useFeatureFlags } from '~/providers/feature-flag';
|
||||
|
||||
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
||||
@@ -47,7 +52,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_SUBSCRIPTIONS_ENABLED && (
|
||||
{isBillingEnabled && (
|
||||
<Link href="/settings/billing">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
||||
|
||||
import { FormErrorMessage } from '../form/form-error-message';
|
||||
import { SignaturePad } from '../signature-pad';
|
||||
|
||||
const ZWidgetFormSchema = z
|
||||
.object({
|
||||
email: z.string().email({ message: 'Please enter a valid email address.' }),
|
||||
name: z.string().min(3, { message: 'Please enter a valid name.' }),
|
||||
})
|
||||
.and(
|
||||
z.union([
|
||||
z.object({
|
||||
signatureDataUrl: z.string().min(1),
|
||||
signatureText: z.null().or(z.string().max(0)),
|
||||
}),
|
||||
z.object({
|
||||
signatureDataUrl: z.null().or(z.string().max(0)),
|
||||
signatureText: z.string().min(1),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
|
||||
|
||||
export type WidgetProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
const { toast } = useToast();
|
||||
const event = usePlausible();
|
||||
|
||||
const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL');
|
||||
const [showSigningDialog, setShowSigningDialog] = useState(false);
|
||||
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
trigger,
|
||||
watch,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm<TWidgetFormSchema>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
email: '',
|
||||
name: '',
|
||||
signatureDataUrl: null,
|
||||
signatureText: '',
|
||||
},
|
||||
resolver: zodResolver(ZWidgetFormSchema),
|
||||
});
|
||||
|
||||
const signatureDataUrl = watch('signatureDataUrl');
|
||||
const signatureText = watch('signatureText');
|
||||
|
||||
const stepsRemaining = useMemo(() => {
|
||||
if (step === 'NAME') {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (step === 'SIGN') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 3;
|
||||
}, [step]);
|
||||
|
||||
const onNextStepClick = () => {
|
||||
if (step === 'EMAIL') {
|
||||
setStep('NAME');
|
||||
|
||||
setTimeout(() => {
|
||||
document.querySelector<HTMLElement>('#name')?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (step === 'NAME') {
|
||||
setStep('SIGN');
|
||||
|
||||
setTimeout(() => {
|
||||
document.querySelector<HTMLElement>('#signatureText')?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const onEnterPress = (callback: () => void) => {
|
||||
return (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
callback();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const onSignatureConfirmClick = () => {
|
||||
setValue('signatureDataUrl', draftSignatureDataUrl);
|
||||
setValue('signatureText', '');
|
||||
|
||||
trigger('signatureDataUrl');
|
||||
setShowSigningDialog(false);
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({
|
||||
email,
|
||||
name,
|
||||
signatureDataUrl,
|
||||
signatureText,
|
||||
}: TWidgetFormSchema) => {
|
||||
try {
|
||||
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
||||
|
||||
const claimPlanInput = signatureDataUrl
|
||||
? {
|
||||
name,
|
||||
email,
|
||||
planId,
|
||||
signatureDataUrl: signatureDataUrl!,
|
||||
signatureText: null,
|
||||
}
|
||||
: {
|
||||
name,
|
||||
email,
|
||||
planId,
|
||||
signatureDataUrl: null,
|
||||
signatureText: signatureText!,
|
||||
};
|
||||
|
||||
const [result] = await Promise.all([claimPlan(claimPlanInput), delay]);
|
||||
|
||||
event('claim-plan-widget');
|
||||
|
||||
window.location.href = result;
|
||||
} catch (error) {
|
||||
event('claim-plan-failed');
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: error instanceof Error ? error.message : 'Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className={cn('mx-auto w-full max-w-4xl rounded-3xl before:rounded-3xl', className)}
|
||||
gradient
|
||||
{...props}
|
||||
>
|
||||
<div className="grid grid-cols-12 gap-y-8 overflow-hidden p-2 lg:gap-x-8">
|
||||
<div className="col-span-12 flex flex-col gap-y-4 p-4 text-xs leading-relaxed text-[#727272] lg:col-span-7">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="col-span-12 flex flex-col rounded-2xl bg-[#F7F7F7] p-6 lg:col-span-5"
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<h3 className="text-2xl font-semibold">Sign up for the community plan</h3>
|
||||
<p className="mt-2 text-xs text-[#AFAFAF]">
|
||||
with Timur Ercan & Lucas Smith from Documenso
|
||||
</p>
|
||||
|
||||
<hr className="mb-6 mt-4" />
|
||||
|
||||
<AnimatePresence>
|
||||
<motion.div key="email">
|
||||
<label htmlFor="email" className="text-lg font-semibold text-slate-900 lg:text-xl">
|
||||
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,21 +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> = {
|
||||
DRAFT: {
|
||||
label: 'Draft',
|
||||
icon: File,
|
||||
color: 'text-yellow-500',
|
||||
},
|
||||
const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
|
||||
PENDING: {
|
||||
label: 'Pending',
|
||||
icon: Clock,
|
||||
@@ -28,10 +24,24 @@ const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-500',
|
||||
},
|
||||
DRAFT: {
|
||||
label: 'Draft',
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||
|
||||
import { TAddFieldsFormSchema } from './add-fields.types';
|
||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
|
||||
export type AddFieldsActionInput = TAddFieldsFormSchema & {
|
||||
documentId: number;
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||
|
||||
import { TAddSignersFormSchema } from './add-signers.types';
|
||||
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
|
||||
export type AddSignersActionInput = TAddSignersFormSchema & {
|
||||
documentId: number;
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
|
||||
import { TAddSubjectFormSchema } from './add-subject.types';
|
||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
|
||||
export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
|
||||
documentId: number;
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||