Compare commits
160 Commits
feat/updat
...
feat/unive
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be52e2fa3 | ||
|
|
0d702e9189 | ||
|
|
425db8fc1f | ||
|
|
2356f58e7b | ||
|
|
6c12ed4afc | ||
|
|
d76ee7f33c | ||
|
|
f8534b2c3d | ||
|
|
9014f01276 | ||
|
|
b255eb21e5 | ||
|
|
9a58178ea5 | ||
|
|
3c36eedfba | ||
|
|
46dfaa70a3 | ||
|
|
61da354a48 | ||
|
|
fbb332fb35 | ||
|
|
7e1cce9155 | ||
|
|
ed4cbe9fa6 | ||
|
|
599e857a1e | ||
|
|
581f08c59b | ||
|
|
24a2e9e6d4 | ||
|
|
e8796a7d86 | ||
|
|
db3f75c42f | ||
|
|
e8b5b3b24a | ||
|
|
00574325b9 | ||
|
|
99706e0ed6 | ||
|
|
326743d8a1 | ||
|
|
3f67b0f27e | ||
|
|
24036b0f24 | ||
|
|
fbf32404a6 | ||
|
|
975d52a07e | ||
|
|
f8a193c0f8 | ||
|
|
9186cb4d7b | ||
|
|
898f5a629c | ||
|
|
933076fa3f | ||
|
|
27edcebef6 | ||
|
|
abc91f7eac | ||
|
|
5862af3034 | ||
|
|
35acf05997 | ||
|
|
5969f148c8 | ||
|
|
660f5894a6 | ||
|
|
77058220a8 | ||
|
|
6cdba45396 | ||
|
|
67571158e8 | ||
|
|
171a5ba4ee | ||
|
|
a2ef9468ae | ||
|
|
ff957a2f82 | ||
|
|
6640f0496a | ||
|
|
de3ebe16ee | ||
|
|
84a2d3baf6 | ||
|
|
74180defd1 | ||
|
|
aeeaaf0d8d | ||
|
|
2b84293c4e | ||
|
|
b38ef6c0a7 | ||
|
|
17af4d25bd | ||
|
|
6e095921e6 | ||
|
|
150c42b246 | ||
|
|
aecf2f32b9 | ||
|
|
b23967d777 | ||
|
|
b3291c65bc | ||
|
|
4b849e286c | ||
|
|
7bcc26a987 | ||
|
|
692722d32e | ||
|
|
e4f06d8e30 | ||
|
|
c799380787 | ||
|
|
5540fcf0d2 | ||
|
|
d9da09c1e7 | ||
|
|
fe90aa3b7b | ||
|
|
0c680e0111 | ||
|
|
7bcf5fbd86 | ||
|
|
7218b950fe | ||
|
|
901013fdc6 | ||
|
|
5c9017f3cd | ||
|
|
34e962cc48 | ||
|
|
bf9254597a | ||
|
|
b5efa0d3ea | ||
|
|
a2bdb46076 | ||
|
|
ed150d9574 | ||
|
|
e756a21fda | ||
|
|
13084049da | ||
|
|
055e723777 | ||
|
|
419318c151 | ||
|
|
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 |
20
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "Documenso",
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/base:bullseye",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||||
|
"version": "latest",
|
||||||
|
"enableNonRootDocker": "true",
|
||||||
|
"moby": "true"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/node:1": {}
|
||||||
|
},
|
||||||
|
"onCreateCommand": "./.devcontainer/on-create.sh",
|
||||||
|
"forwardPorts": [
|
||||||
|
3000,
|
||||||
|
54320,
|
||||||
|
9000,
|
||||||
|
2500,
|
||||||
|
1100
|
||||||
|
]
|
||||||
|
}
|
||||||
18
.devcontainer/on-create.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Start the database and mailserver
|
||||||
|
docker compose -f ./docker/compose-without-app.yml up -d
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Copy the env file
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Source the env file, export the variables
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
# Run the migrations
|
||||||
|
npm run -w @documenso/prisma prisma:migrate-dev
|
||||||
3
.devcontainer/post-start.sh
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
npm run dev
|
||||||
16
.env.example
@@ -12,6 +12,22 @@ NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
|||||||
|
|
||||||
# [[DATABASE]]
|
# [[DATABASE]]
|
||||||
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
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"
|
||||||
|
|
||||||
|
# [[STORAGE]]
|
||||||
|
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||||
|
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
||||||
|
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
|
||||||
|
NEXT_PRIVATE_UPLOAD_ENDPOINT=
|
||||||
|
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
|
||||||
|
NEXT_PRIVATE_UPLOAD_REGION=
|
||||||
|
# REQUIRED: Defines the bucket to use for the S3 storage transport.
|
||||||
|
NEXT_PRIVATE_UPLOAD_BUCKET=
|
||||||
|
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
|
||||||
|
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=
|
||||||
|
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
|
||||||
|
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
# [[SMTP]]
|
# [[SMTP]]
|
||||||
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
||||||
|
|||||||
7
.eslintignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Config files
|
||||||
|
*.config.js
|
||||||
|
*.config.cjs
|
||||||
|
|
||||||
|
# Statically hosted javascript files
|
||||||
|
apps/*/public/*.js
|
||||||
|
apps/*/public/*.cjs
|
||||||
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 }}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: 'Building Documenso — Part 1: Certificates'
|
title: 'Building Documenso — Part 1: Certificates'
|
||||||
description: In today's fast-paced world, productivity and efficiency are crucial for success, both in personal and professional endeavors. We all strive to make the most of our time and energy to achieve our goals effectively. However, it's not always easy to stay on track and maintain peak performance. In this blog post, we'll explore 10 valuable tips to help you boost productivity and efficiency in your daily life.
|
description: This is the first part of the new Building Documenso series, where I describe the challenges and design choices that we make while building the world’s most open signing platform.
|
||||||
authorName: 'Timur Ercan'
|
authorName: 'Timur Ercan'
|
||||||
authorImage: '/blog/blog-author-timur.jpeg'
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
authorRole: 'Co-Founder'
|
authorRole: 'Co-Founder'
|
||||||
@@ -79,7 +79,7 @@ There weren’t any deeper reasons we choose WiseKey, other than they offered wh
|
|||||||
Do you have questions or thoughts about this? As always, let me know in the comments, on <a href="http://twitter.com/eltimuro" target="_blank">twitter.com/eltimuro</a>
|
Do you have questions or thoughts about this? As always, let me know in the comments, on <a href="http://twitter.com/eltimuro" target="_blank">twitter.com/eltimuro</a>
|
||||||
or directly: <a href="https://documen.so/timur" target="_blank">documen.so/timur</a>
|
or directly: <a href="https://documen.so/timur" target="_blank">documen.so/timur</a>
|
||||||
|
|
||||||
Join the self-hoster community here: <a href="https://documenso.slack.com/" target="_blank">https://documenso.slack.com/</a>
|
Join the self-hoster community here: <a href="https://documen.so/discord" target="_blank">https://documen.so/discord</a>
|
||||||
|
|
||||||
Best from Hamburg
|
Best from Hamburg
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ tags:
|
|||||||
|
|
||||||
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.
|
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).
|
Last week, Lucas shared the reasoning on [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.
|
Today, I'm pleased to share with you a preview of the next Documenso.
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,16 @@ const { parsed: env } = require('dotenv').config({
|
|||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
experimental: {
|
||||||
|
serverActions: true,
|
||||||
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
||||||
env,
|
modularizeImports: {
|
||||||
|
'lucide-react': {
|
||||||
|
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withContentlayer(config);
|
module.exports = withContentlayer(config);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.8.0",
|
||||||
"recharts": "^2.7.2",
|
"recharts": "^2.7.2",
|
||||||
|
"sharp": "0.32.5",
|
||||||
"typescript": "5.1.6",
|
"typescript": "5.1.6",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { allDocuments } from 'contentlayer/generated';
|
|||||||
import type { MDXComponents } from 'mdx/types';
|
import type { MDXComponents } from 'mdx/types';
|
||||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||||
|
|
||||||
export const generateStaticParams = async () =>
|
export const generateStaticParams = () =>
|
||||||
allDocuments.map((post) => ({ post: post._raw.flattenedPath }));
|
allDocuments.map((post) => ({ post: post._raw.flattenedPath }));
|
||||||
|
|
||||||
export const generateMetadata = ({ params }: { params: { content: string } }) => {
|
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 type { MDXComponents } from 'mdx/types';
|
||||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||||
|
|
||||||
export const generateStaticParams = async () =>
|
export const generateStaticParams = () =>
|
||||||
allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
|
allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
|
||||||
|
|
||||||
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||||
@@ -17,7 +17,9 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { title: `Documenso - ${blogPost.title}` };
|
return {
|
||||||
|
title: `Documenso - ${blogPost.title}`,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mdxComponents: MDXComponents = {
|
const mdxComponents: MDXComponents = {
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ export type ClaimedPlanPageProps = {
|
|||||||
export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlanPageProps) {
|
export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlanPageProps) {
|
||||||
const { sessionId } = searchParams;
|
const { sessionId } = searchParams;
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.retrieve(sessionId as string);
|
if (typeof sessionId !== 'string') {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -157,7 +161,6 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
href={`${process.env.NEXT_PUBLIC_APP_URL}/login`}
|
href={`${process.env.NEXT_PUBLIC_APP_URL}/login`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="mt-4 block"
|
className="mt-4 block"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { FundingRaised } from './funding-raised';
|
|||||||
import { GithubMetric } from './gh-metrics';
|
import { GithubMetric } from './gh-metrics';
|
||||||
import { TeamMembers } from './team-members';
|
import { TeamMembers } from './team-members';
|
||||||
|
|
||||||
export const revalidate = 86400;
|
export const revalidate = 3600;
|
||||||
|
|
||||||
const ZGithubStatsResponse = z.object({
|
const ZGithubStatsResponse = z.object({
|
||||||
stargazers_count: z.number(),
|
stargazers_count: z.number(),
|
||||||
@@ -43,7 +43,7 @@ export default async function OpenPage() {
|
|||||||
accept: 'application/vnd.github.v3+json',
|
accept: 'application/vnd.github.v3+json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZGithubStatsResponse.parse(res));
|
.then((res) => ZGithubStatsResponse.parse(res));
|
||||||
|
|
||||||
const { total_count: mergedPullRequests } = await fetch(
|
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));
|
.then((res) => ZMergedPullRequestsResponse.parse(res));
|
||||||
|
|
||||||
const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', {
|
const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', {
|
||||||
@@ -62,7 +62,7 @@ export default async function OpenPage() {
|
|||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZStargazersLiveResponse.parse(res));
|
.then((res) => ZStargazersLiveResponse.parse(res));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default async function IndexPage() {
|
|||||||
accept: 'application/vnd.github.v3+json',
|
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))
|
.then((res) => (typeof res.stargazers_count === 'number' ? res.stargazers_count : undefined))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
|||||||
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
@@ -63,7 +63,9 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
|
|
||||||
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
|
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
const delay = new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
const [redirectUrl] = await Promise.all([
|
const [redirectUrl] = await Promise.all([
|
||||||
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
|
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
|
|
||||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
const SOCIAL_LINKS = [
|
||||||
|
{ href: 'https://twitter.com/documenso', icon: <Twitter className="h-6 w-6" /> },
|
||||||
|
{ href: 'https://github.com/documenso/documenso', icon: <Github className="h-6 w-6" /> },
|
||||||
|
{ href: 'https://documen.so/discord', icon: <MessagesSquare className="h-6 w-6" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FOOTER_LINKS = [
|
||||||
|
{ href: '/pricing', text: 'Pricing' },
|
||||||
|
{ href: '/blog', text: 'Blog' },
|
||||||
|
{ href: '/open', text: 'Open' },
|
||||||
|
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
||||||
|
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
|
||||||
|
{ href: 'mailto:support@documenso.com', text: 'Support' },
|
||||||
|
{ href: '/privacy', text: 'Privacy' },
|
||||||
|
];
|
||||||
|
|
||||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
export const Footer = ({ className, ...props }: FooterProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn('border-t py-12', className)} {...props}>
|
<div className={cn('border-t py-12', className)} {...props}>
|
||||||
@@ -19,77 +35,25 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-4 text-[#8D8D8D]">
|
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-4 text-[#8D8D8D]">
|
||||||
<Link
|
{SOCIAL_LINKS.map((link, index) => (
|
||||||
href="https://twitter.com/documenso"
|
<Link key={index} href={link.href} target="_blank" className="hover:text-[#6D6D6D]">
|
||||||
target="_blank"
|
{link.icon}
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<Twitter className="h-6 w-6" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://github.com/documenso/documenso"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<Github className="h-6 w-6" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://documen.so/discord"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<MessagesSquare className="h-6 w-6" />
|
|
||||||
</Link>
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
||||||
|
{FOOTER_LINKS.map((link, index) => (
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
key={index}
|
||||||
|
href={link.href}
|
||||||
|
target={link.target}
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||||
>
|
>
|
||||||
Pricing
|
{link.text}
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/blog" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
|
|
||||||
Blog
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/open" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
|
|
||||||
Open
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://shop.documenso.com"
|
|
||||||
target="_blank"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Shop
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://status.documenso.com"
|
|
||||||
target="_blank"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Status
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="mailto:support@documenso.com"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Support
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/privacy"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Privacy
|
|
||||||
</Link>
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto mt-4 w-full max-w-screen-xl px-8 md:mt-12 lg:mt-24">
|
<div className="mx-auto mt-4 w-full max-w-screen-xl px-8 md:mt-12 lg:mt-24">
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export const MENU_NAVIGATION_LINKS = [
|
|||||||
href: '/pricing',
|
href: '/pricing',
|
||||||
text: 'Pricing',
|
text: 'Pricing',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/open',
|
||||||
|
text: 'Open',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: 'https://status.documenso.com',
|
href: 'https://status.documenso.com',
|
||||||
text: 'Status',
|
text: 'Status',
|
||||||
@@ -59,7 +63,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
transition={{
|
transition={{
|
||||||
staggerChildren: 0.2,
|
staggerChildren: 0.03,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
|
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
|
||||||
@@ -75,6 +79,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
x: 0,
|
x: 0,
|
||||||
transition: {
|
transition: {
|
||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
|
ease: 'backInOut',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const PasswordReveal = ({ password }: PasswordRevealProps) => {
|
|||||||
const [, copy] = useCopyToClipboard();
|
const [, copy] = useCopyToClipboard();
|
||||||
|
|
||||||
const onCopyClick = () => {
|
const onCopyClick = () => {
|
||||||
copy(password).then(() => {
|
void copy(password).then(() => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Copied to clipboard',
|
title: 'Copied to clipboard',
|
||||||
description: 'Your password has been copied to your clipboard.',
|
description: 'Your password has been copied to your clipboard.',
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
const event = usePlausible();
|
const event = usePlausible();
|
||||||
|
|
||||||
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() =>
|
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() =>
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
|
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
|
||||||
? 'YEARLY'
|
? 'YEARLY'
|
||||||
: 'MONTHLY',
|
: 'MONTHLY',
|
||||||
@@ -30,11 +29,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
|
|
||||||
const planId = useMemo(() => {
|
const planId = useMemo(() => {
|
||||||
if (period === 'MONTHLY') {
|
if (period === 'MONTHLY') {
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID;
|
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID;
|
||||||
}, [period]);
|
}, [period]);
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
setValue('signatureDataUrl', draftSignatureDataUrl);
|
setValue('signatureDataUrl', draftSignatureDataUrl);
|
||||||
setValue('signatureText', '');
|
setValue('signatureText', '');
|
||||||
|
|
||||||
trigger('signatureDataUrl');
|
void trigger('signatureDataUrl');
|
||||||
setShowSigningDialog(false);
|
setShowSigningDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,9 +135,10 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
signatureText,
|
signatureText,
|
||||||
}: TWidgetFormSchema) => {
|
}: TWidgetFormSchema) => {
|
||||||
try {
|
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 planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
||||||
|
|
||||||
const claimPlanInput = signatureDataUrl
|
const claimPlanInput = signatureDataUrl
|
||||||
@@ -145,7 +146,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
planId,
|
planId,
|
||||||
signatureDataUrl: signatureDataUrl!,
|
signatureDataUrl: signatureDataUrl,
|
||||||
signatureText: null,
|
signatureText: null,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@@ -153,7 +154,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
email,
|
email,
|
||||||
planId,
|
planId,
|
||||||
signatureDataUrl: null,
|
signatureDataUrl: null,
|
||||||
signatureText: signatureText!,
|
signatureText: signatureText ?? '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const [result] = await Promise.all([claimPlan(claimPlanInput), delay]);
|
const [result] = await Promise.all([claimPlan(claimPlanInput), delay]);
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export default async function handler(
|
|||||||
|
|
||||||
if (user && user.Subscription.length > 0) {
|
if (user && user.Subscription.length > 0) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -104,7 +103,6 @@ export default async function handler(
|
|||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
metadata,
|
metadata,
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in
|
|||||||
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
|
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
|
||||||
import { redis } from '@documenso/lib/server-only/redis';
|
import { redis } from '@documenso/lib/server-only/redis';
|
||||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
|
import { updateFile } from '@documenso/lib/universal/upload/update-file';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import {
|
import {
|
||||||
|
DocumentDataType,
|
||||||
DocumentStatus,
|
DocumentStatus,
|
||||||
FieldType,
|
FieldType,
|
||||||
ReadStatus,
|
ReadStatus,
|
||||||
@@ -17,14 +20,13 @@ import {
|
|||||||
SigningStatus,
|
SigningStatus,
|
||||||
} from '@documenso/prisma/client';
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
const log = (...args: any[]) => console.log('[stripe]', ...args);
|
const log = (...args: unknown[]) => console.log('[stripe]', ...args);
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: { bodyParser: false },
|
api: { bodyParser: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
|
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
|
||||||
// return res.status(500).json({
|
// return res.status(500).json({
|
||||||
// success: false,
|
// success: false,
|
||||||
@@ -55,6 +57,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
log('event-type:', event.type);
|
log('event-type:', event.type);
|
||||||
|
|
||||||
if (event.type === 'checkout.session.completed') {
|
if (event.type === 'checkout.session.completed') {
|
||||||
|
// This typecast is required since we don't want to create a guard for every event type
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const session = event.data.object as Stripe.Checkout.Session;
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
|
||||||
if (session.metadata?.source === 'landing') {
|
if (session.metadata?.source === 'landing') {
|
||||||
@@ -84,16 +88,34 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
|
||||||
|
|
||||||
|
const { id: documentDataId } = await prisma.documentData.create({
|
||||||
|
data: {
|
||||||
|
type: DocumentDataType.BYTES_64,
|
||||||
|
data: bytes64,
|
||||||
|
initialData: bytes64,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const document = await prisma.document.create({
|
const document = await prisma.document.create({
|
||||||
data: {
|
data: {
|
||||||
title: 'Documenso Supporter Pledge.pdf',
|
title: 'Documenso Supporter Pledge.pdf',
|
||||||
status: DocumentStatus.COMPLETED,
|
status: DocumentStatus.COMPLETED,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
document: readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'),
|
documentDataId,
|
||||||
created: now,
|
},
|
||||||
|
include: {
|
||||||
|
documentData: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { documentData } = document;
|
||||||
|
|
||||||
|
if (!documentData) {
|
||||||
|
throw new Error(`Document ${document.id} has no document data`);
|
||||||
|
}
|
||||||
|
|
||||||
const recipient = await prisma.recipient.create({
|
const recipient = await prisma.recipient.create({
|
||||||
data: {
|
data: {
|
||||||
name: user.name ?? '',
|
name: user.name ?? '',
|
||||||
@@ -120,17 +142,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let pdfData = await getFile(documentData).then((data) =>
|
||||||
|
Buffer.from(data).toString('base64'),
|
||||||
|
);
|
||||||
|
|
||||||
if (signatureDataUrl) {
|
if (signatureDataUrl) {
|
||||||
document.document = await insertImageInPDF(
|
pdfData = await insertImageInPDF(
|
||||||
document.document,
|
pdfData,
|
||||||
signatureDataUrl,
|
signatureDataUrl,
|
||||||
Number(field.positionX),
|
Number(field.positionX),
|
||||||
Number(field.positionY),
|
Number(field.positionY),
|
||||||
field.page,
|
field.page,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
document.document = await insertTextInPDF(
|
pdfData = await insertTextInPDF(
|
||||||
document.document,
|
pdfData,
|
||||||
signatureText ?? '',
|
signatureText ?? '',
|
||||||
Number(field.positionX),
|
Number(field.positionX),
|
||||||
Number(field.positionY),
|
Number(field.positionY),
|
||||||
@@ -138,6 +164,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { data: newData } = await updateFile({
|
||||||
|
type: documentData.type,
|
||||||
|
oldData: documentData.initialData,
|
||||||
|
newData: Buffer.from(pdfData, 'base64').toString('binary'),
|
||||||
|
});
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.signature.create({
|
prisma.signature.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -147,12 +179,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
typedSignature: signatureDataUrl ? '' : signatureText,
|
typedSignature: signatureDataUrl ? '' : signatureText,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.document.update({
|
prisma.documentData.update({
|
||||||
where: {
|
where: {
|
||||||
id: document.id,
|
id: documentData.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
document: document.document,
|
data: newData,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { version } = require('./package.json');
|
||||||
|
|
||||||
const { parsed: env } = require('dotenv').config({
|
const { parsed: env } = require('dotenv').config({
|
||||||
path: path.join(__dirname, '../../.env.local'),
|
path: path.join(__dirname, '../../.env.local'),
|
||||||
@@ -9,6 +10,7 @@ const { parsed: env } = require('dotenv').config({
|
|||||||
const config = {
|
const config = {
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: true,
|
serverActions: true,
|
||||||
|
serverActionsBodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
@@ -18,7 +20,9 @@ const config = {
|
|||||||
'@documenso/ui',
|
'@documenso/ui',
|
||||||
'@documenso/email',
|
'@documenso/email',
|
||||||
],
|
],
|
||||||
env,
|
env: {
|
||||||
|
APP_VERSION: version,
|
||||||
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@documenso/ee": "*",
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
@@ -21,8 +22,8 @@
|
|||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.214.0",
|
"lucide-react": "^0.214.0",
|
||||||
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"nanoid": "^4.0.2",
|
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
"next-auth": "4.22.3",
|
"next-auth": "4.22.3",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
@@ -36,12 +37,14 @@
|
|||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.8.0",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
|
"sharp": "0.32.5",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.1.6",
|
"typescript": "5.1.6",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7"
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import { TCreateDocumentRequestSchema, ZCreateDocumentResponseSchema } from './types';
|
|
||||||
|
|
||||||
export const useCreateDocument = () => {
|
|
||||||
return useMutation(async ({ file }: TCreateDocumentRequestSchema) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.set('file', file);
|
|
||||||
|
|
||||||
const response = await fetch('/api/document/create', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = await response.json();
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
throw new Error('Failed to create document');
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeBody = ZCreateDocumentResponseSchema.safeParse(body);
|
|
||||||
|
|
||||||
if (!safeBody.success) {
|
|
||||||
throw new Error('Failed to create document');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('error' in safeBody.data) {
|
|
||||||
throw new Error(safeBody.data.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return safeBody.data;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const ZCreateDocumentRequestSchema = z.object({
|
|
||||||
file: z.instanceof(File),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TCreateDocumentRequestSchema = z.infer<typeof ZCreateDocumentRequestSchema>;
|
|
||||||
|
|
||||||
export const ZCreateDocumentResponseSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.number(),
|
|
||||||
})
|
|
||||||
.or(
|
|
||||||
z.object({
|
|
||||||
error: z.string(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TCreateDocumentResponseSchema = z.infer<typeof ZCreateDocumentResponseSchema>;
|
|
||||||
30
apps/web/src/app/(dashboard)/admin/layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
|
|
||||||
|
import { AdminNav } from './nav';
|
||||||
|
|
||||||
|
export type AdminSectionLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
|
||||||
|
const user = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
if (!isAdmin(user)) {
|
||||||
|
redirect('/documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto mt-16 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<div className="grid grid-cols-12 gap-x-8 md:mt-8">
|
||||||
|
<AdminNav className="col-span-12 md:col-span-3 md:flex" />
|
||||||
|
|
||||||
|
<div className="col-span-12 mt-12 md:col-span-9 md:mt-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
apps/web/src/app/(dashboard)/admin/nav.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { BarChart3, User2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export type AdminNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex gap-x-2.5 gap-y-2 md:flex-col', className)} {...props}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'justify-start md:w-full',
|
||||||
|
pathname?.startsWith('/admin/stats') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/admin/stats">
|
||||||
|
<BarChart3 className="mr-2 h-5 w-5" />
|
||||||
|
Stats
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'justify-start md:w-full',
|
||||||
|
pathname?.startsWith('/admin/users') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<User2 className="mr-2 h-5 w-5" />
|
||||||
|
Users (Coming Soon)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
apps/web/src/app/(dashboard)/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
redirect('/admin/stats');
|
||||||
|
}
|
||||||
75
apps/web/src/app/(dashboard)/admin/stats/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
File,
|
||||||
|
FileCheck,
|
||||||
|
FileClock,
|
||||||
|
FileEdit,
|
||||||
|
Mail,
|
||||||
|
MailOpen,
|
||||||
|
PenTool,
|
||||||
|
User as UserIcon,
|
||||||
|
UserPlus2,
|
||||||
|
UserSquare2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||||
|
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||||
|
import {
|
||||||
|
getUsersCount,
|
||||||
|
getUsersWithSubscriptionsCount,
|
||||||
|
} from '@documenso/lib/server-only/admin/get-users-stats';
|
||||||
|
|
||||||
|
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||||
|
|
||||||
|
export default async function AdminStatsPage() {
|
||||||
|
const [usersCount, usersWithSubscriptionsCount, docStats, recipientStats] = await Promise.all([
|
||||||
|
getUsersCount(),
|
||||||
|
getUsersWithSubscriptionsCount(),
|
||||||
|
getDocumentStats(),
|
||||||
|
getRecipientsStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-4xl font-semibold">Instance Stats</h2>
|
||||||
|
|
||||||
|
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<CardMetric icon={UserIcon} title="Total Users" value={usersCount} />
|
||||||
|
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
|
||||||
|
<CardMetric
|
||||||
|
icon={UserPlus2}
|
||||||
|
title="Active Subscriptions"
|
||||||
|
value={usersWithSubscriptionsCount}
|
||||||
|
/>
|
||||||
|
<CardMetric icon={UserPlus2} title="App Version" value={`v${process.env.APP_VERSION}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-3xl font-semibold">Document metrics</h3>
|
||||||
|
|
||||||
|
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
|
||||||
|
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
|
||||||
|
<CardMetric icon={FileEdit} title="Drafted Documents" value={docStats.DRAFT} />
|
||||||
|
<CardMetric icon={FileClock} title="Pending Documents" value={docStats.PENDING} />
|
||||||
|
<CardMetric icon={FileCheck} title="Completed Documents" value={docStats.COMPLETED} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
|
||||||
|
|
||||||
|
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
|
||||||
|
<CardMetric
|
||||||
|
icon={UserSquare2}
|
||||||
|
title="Total Recipients"
|
||||||
|
value={recipientStats.TOTAL_RECIPIENTS}
|
||||||
|
/>
|
||||||
|
<CardMetric icon={Mail} title="Documents Received" value={recipientStats.SENT} />
|
||||||
|
<CardMetric icon={MailOpen} title="Documents Viewed" value={recipientStats.OPENED} />
|
||||||
|
<CardMetric icon={PenTool} title="Signatures Collected" value={recipientStats.SIGNED} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
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 {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@documenso/ui/primitives/table';
|
|
||||||
|
|
||||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
|
||||||
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
import { UploadDocument } from './upload-document';
|
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
|
||||||
const session = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const [stats, results] = await Promise.all([
|
|
||||||
getStats({
|
|
||||||
userId: session.id,
|
|
||||||
}),
|
|
||||||
findDocuments({
|
|
||||||
userId: session.id,
|
|
||||||
perPage: 10,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12">
|
|
||||||
<UploadDocument />
|
|
||||||
|
|
||||||
<h2 className="mt-8 text-2xl font-semibold">Recent Documents</h2>
|
|
||||||
|
|
||||||
<div className="border-border mt-8 overflow-x-auto rounded-lg border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-[100px]">ID</TableHead>
|
|
||||||
<TableHead>Title</TableHead>
|
|
||||||
<TableHead>Reciepient</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead className="text-right">Created</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{results.data.map((document) => {
|
|
||||||
return (
|
|
||||||
<TableRow key={document.id}>
|
|
||||||
<TableCell className="font-medium">{document.id}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Link
|
|
||||||
href={`/documents/${document.id}`}
|
|
||||||
className="focus-visible:ring-ring ring-offset-background rounded-md font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
{document.title}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<StackAvatarsWithTooltip recipients={document.Recipient} />
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<DocumentStatus status={document.status} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<LocaleDate date={document.created} />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{results.data.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={4} className="h-24 text-center">
|
|
||||||
No results.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,8 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
import { Field, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
@@ -28,9 +29,10 @@ import { completeDocument } from '~/components/forms/edit-document/add-subject.a
|
|||||||
export type EditDocumentFormProps = {
|
export type EditDocumentFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
user: User;
|
||||||
document: Document;
|
document: DocumentWithData;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
dataUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
||||||
@@ -41,14 +43,13 @@ export const EditDocumentForm = ({
|
|||||||
recipients,
|
recipients,
|
||||||
fields,
|
fields,
|
||||||
user: _user,
|
user: _user,
|
||||||
|
dataUrl,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [step, setStep] = useState<EditDocumentStep>('signers');
|
const [step, setStep] = useState<EditDocumentStep>('signers');
|
||||||
|
|
||||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
|
||||||
|
|
||||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
signers: {
|
signers: {
|
||||||
title: 'Add Signers',
|
title: 'Add Signers',
|
||||||
@@ -130,7 +131,13 @@ export const EditDocumentForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
toast({
|
||||||
|
title: 'Document sent',
|
||||||
|
description: 'Your document has been sent successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push('/documents');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@@ -145,11 +152,11 @@ export const EditDocumentForm = ({
|
|||||||
return (
|
return (
|
||||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||||
<Card
|
<Card
|
||||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer document={documentUrl} />
|
<LazyPDFViewer document={dataUrl} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
|
|
||||||
return (
|
|
||||||
<Card className={className} gradient {...props}>
|
|
||||||
<CardContent className="p-2">
|
|
||||||
<LazyPDFViewer className={pdfClassName} {...props} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -7,6 +7,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
|||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
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 { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
@@ -36,10 +37,16 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
userId: session.id,
|
userId: session.id,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!document) {
|
if (!document || !document.documentData) {
|
||||||
redirect('/documents');
|
redirect('/documents');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { documentData } = document;
|
||||||
|
|
||||||
|
const documentDataUrl = await getFile(documentData)
|
||||||
|
.then((buffer) => Buffer.from(buffer).toString('base64'))
|
||||||
|
.then((data) => `data:application/pdf;base64,${data}`);
|
||||||
|
|
||||||
const [recipients, fields] = await Promise.all([
|
const [recipients, fields] = await Promise.all([
|
||||||
await getRecipientsForDocument({
|
await getRecipientsForDocument({
|
||||||
documentId,
|
documentId,
|
||||||
@@ -86,12 +93,13 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
user={session}
|
user={session}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
dataUrl={documentDataUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||||
<div className="mx-auto mt-12 max-w-2xl">
|
<div className="mx-auto mt-12 max-w-2xl">
|
||||||
<LazyPDFViewer document={`data:application/pdf;base64,${document.document}`} />
|
<LazyPDFViewer document={documentDataUrl} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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,145 @@
|
|||||||
|
'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 { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
|
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
|
import { trpc } from '@documenso/trpc/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 = async () => {
|
||||||
|
let document: DocumentWithData | null = null;
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
document = await trpc.document.getDocumentById.query({
|
||||||
|
id: row.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document = await trpc.document.getDocumentByToken.query({
|
||||||
|
token: recipient.token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentData = document?.documentData;
|
||||||
|
|
||||||
|
if (!documentData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentBytes = await getFile(documentData);
|
||||||
|
|
||||||
|
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 || isComplete} asChild>
|
||||||
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Sign
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled={!isOwner || isComplete} 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
56
apps/web/src/app/(dashboard)/documents/data-table-title.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type DataTableTitleProps = {
|
||||||
|
row: Document & {
|
||||||
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
Recipient: Recipient[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
|
||||||
|
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;
|
||||||
|
|
||||||
|
return match({
|
||||||
|
isOwner,
|
||||||
|
isRecipient,
|
||||||
|
})
|
||||||
|
.with({ isOwner: true }, () => (
|
||||||
|
<Link
|
||||||
|
href={`/documents/${row.id}`}
|
||||||
|
title={row.title}
|
||||||
|
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
.with({ isRecipient: true }, () => (
|
||||||
|
<Link
|
||||||
|
href={`/sign/${recipient?.token}`}
|
||||||
|
title={row.title}
|
||||||
|
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
|
||||||
|
{row.title}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
};
|
||||||
@@ -2,13 +2,12 @@
|
|||||||
|
|
||||||
import { useTransition } from 'react';
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
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 { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
|
||||||
@@ -16,11 +15,21 @@ import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-a
|
|||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
import { DataTableActionButton } from './data-table-action-button';
|
||||||
|
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||||
|
import { DataTableTitle } from './data-table-title';
|
||||||
|
|
||||||
export type DocumentsDataTableProps = {
|
export type DocumentsDataTableProps = {
|
||||||
results: FindResultSet<DocumentWithReciepient>;
|
results: FindResultSet<
|
||||||
|
Document & {
|
||||||
|
Recipient: Recipient[];
|
||||||
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
@@ -34,21 +43,22 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
header: 'ID',
|
header: 'Created',
|
||||||
accessorKey: 'id',
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Title',
|
header: 'Title',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||||
<Link href={`/documents/${row.original.id}`} className="font-medium hover:underline">
|
|
||||||
{row.original.title}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Recipient',
|
header: 'Recipient',
|
||||||
@@ -63,9 +73,13 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Created',
|
header: 'Actions',
|
||||||
accessorKey: 'created',
|
cell: ({ row }) => (
|
||||||
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
|
<div className="flex items-center gap-x-4">
|
||||||
|
<DataTableActionButton row={row.original} />
|
||||||
|
<DataTableActionDropdown row={row.original} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
data={results.data}
|
data={results.data}
|
||||||
@@ -74,7 +88,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
totalPages={results.totalPages}
|
totalPages={results.totalPages}
|
||||||
onPaginationChange={onPaginationChange}
|
onPaginationChange={onPaginationChange}
|
||||||
>
|
>
|
||||||
{(table) => <DataTablePagination table={table} />}
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
{isPending && (
|
{isPending && (
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ import Link from 'next/link';
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||||
import { isDocumentStatus } from '@documenso/lib/types/is-document-status';
|
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||||
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
import { UploadDocument } from '../dashboard/upload-document';
|
|
||||||
import { DocumentsDataTable } from './data-table';
|
import { DocumentsDataTable } from './data-table';
|
||||||
|
import { UploadDocument } from './upload-document';
|
||||||
|
|
||||||
export type DocumentsPageProps = {
|
export type DocumentsPageProps = {
|
||||||
searchParams?: {
|
searchParams?: {
|
||||||
status?: InternalDocumentStatus | 'ALL';
|
status?: ExtendedDocumentStatus;
|
||||||
period?: PeriodSelectorValue;
|
period?: PeriodSelectorValue;
|
||||||
page?: string;
|
page?: string;
|
||||||
perPage?: string;
|
perPage?: string;
|
||||||
@@ -24,24 +24,22 @@ export type DocumentsPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||||
const session = await getRequiredServerComponentSession();
|
const user = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const stats = await getStats({
|
const stats = await getStats({
|
||||||
userId: session.id,
|
user,
|
||||||
});
|
});
|
||||||
|
|
||||||
const status = isDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||||
// const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
// const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||||
const page = Number(searchParams.page) || 1;
|
const page = Number(searchParams.page) || 1;
|
||||||
const perPage = Number(searchParams.perPage) || 20;
|
const perPage = Number(searchParams.perPage) || 20;
|
||||||
|
|
||||||
const shouldDefaultToPending = status === 'ALL' && stats.PENDING > 0;
|
|
||||||
|
|
||||||
const results = await findDocuments({
|
const results = await findDocuments({
|
||||||
userId: session.id,
|
userId: user.id,
|
||||||
status: status === 'ALL' ? undefined : status,
|
status,
|
||||||
orderBy: {
|
orderBy: {
|
||||||
column: 'created',
|
column: 'createdAt',
|
||||||
direction: 'desc',
|
direction: 'desc',
|
||||||
},
|
},
|
||||||
page,
|
page,
|
||||||
@@ -57,10 +55,6 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
params.delete('page');
|
params.delete('page');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === 'ALL') {
|
|
||||||
params.delete('status');
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/documents?${params.toString()}`;
|
return `/documents?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,41 +65,28 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
<h1 className="mt-12 text-4xl font-semibold">Documents</h1>
|
<h1 className="mt-12 text-4xl font-semibold">Documents</h1>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
|
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
|
||||||
<Tabs defaultValue={shouldDefaultToPending ? InternalDocumentStatus.PENDING : status}>
|
<Tabs defaultValue={status} className="overflow-x-auto">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.PENDING} asChild>
|
{[
|
||||||
<Link href={getTabHref(InternalDocumentStatus.PENDING)}>
|
ExtendedDocumentStatus.INBOX,
|
||||||
<DocumentStatus status={InternalDocumentStatus.PENDING} />
|
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} />
|
||||||
|
|
||||||
|
{value !== ExtendedDocumentStatus.ALL && (
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||||
{Math.min(stats.PENDING, 99)}
|
{Math.min(stats[value], 99)}
|
||||||
|
{stats[value] > 99 && '+'}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.COMPLETED} asChild>
|
|
||||||
<Link href={getTabHref(InternalDocumentStatus.COMPLETED)}>
|
|
||||||
<DocumentStatus status={InternalDocumentStatus.COMPLETED} />
|
|
||||||
|
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
|
||||||
{Math.min(stats.COMPLETED, 99)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
|
|
||||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.DRAFT} asChild>
|
|
||||||
<Link href={getTabHref(InternalDocumentStatus.DRAFT)}>
|
|
||||||
<DocumentStatus status={InternalDocumentStatus.DRAFT} />
|
|
||||||
|
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
|
||||||
{Math.min(stats.DRAFT, 99)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
|
|
||||||
<TabsTrigger className="min-w-[60px]" value="ALL" asChild>
|
|
||||||
<Link href={getTabHref('ALL')}>All</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,45 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCreateDocument } from '~/api/document/create/fetcher';
|
|
||||||
|
|
||||||
export type UploadDocumentProps = {
|
export type UploadDocumentProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||||
const { toast } = useToast();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { isLoading, mutateAsync: createDocument } = useCreateDocument();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const { type, data } = await putFile(file);
|
||||||
|
|
||||||
|
const { id: documentDataId } = await createDocumentData({
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
const { id } = await createDocument({
|
const { id } = await createDocument({
|
||||||
file: file,
|
title: file.name,
|
||||||
|
documentDataId,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -41,6 +57,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
description: 'An error occurred while uploading your document.',
|
description: 'An error occurred while uploading your document.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||||
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { 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';
|
import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
|
||||||
|
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
@@ -15,17 +21,57 @@ export default async function BillingSettingsPage() {
|
|||||||
redirect('/settings/profile');
|
redirect('/settings/profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => {
|
||||||
|
if (sub) {
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we don't have a customer record, create one as well as an empty subscription.
|
||||||
|
return createCustomer({ user });
|
||||||
|
});
|
||||||
|
|
||||||
|
let billingPortalUrl = '';
|
||||||
|
|
||||||
|
if (subscription.customerId) {
|
||||||
|
billingPortalUrl = await getPortalSession({
|
||||||
|
customerId: subscription.customerId,
|
||||||
|
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Billing</h3>
|
<h3 className="text-lg font-medium">Billing</h3>
|
||||||
|
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
<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>
|
</p>
|
||||||
|
|
||||||
<hr className="my-4" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,42 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes, useState } from 'react';
|
||||||
|
|
||||||
import { Download } from 'lucide-react';
|
import { Download } from 'lucide-react';
|
||||||
|
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
|
import { DocumentData } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
document?: string;
|
documentData?: DocumentData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DownloadButton = ({
|
export const DownloadButton = ({
|
||||||
className,
|
className,
|
||||||
fileName,
|
fileName,
|
||||||
document,
|
documentData,
|
||||||
disabled,
|
disabled,
|
||||||
...props
|
...props
|
||||||
}: DownloadButtonProps) => {
|
}: DownloadButtonProps) => {
|
||||||
/**
|
const { toast } = useToast();
|
||||||
* Convert the document from base64 to a blob and download it.
|
|
||||||
*/
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const onDownloadClick = () => {
|
|
||||||
if (!document) {
|
const onDownloadClick = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
if (!documentData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let decodedDocument = document;
|
const bytes = await getFile(documentData);
|
||||||
|
|
||||||
try {
|
const blob = new Blob([bytes], {
|
||||||
decodedDocument = atob(document);
|
|
||||||
} 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',
|
type: 'application/pdf',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,6 +48,17 @@ export const DownloadButton = ({
|
|||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
window.URL.revokeObjectURL(link.href);
|
window.URL.revokeObjectURL(link.href);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while downloading your document.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,8 +66,9 @@ export const DownloadButton = ({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={className}
|
className={className}
|
||||||
disabled={disabled || !document}
|
disabled={disabled || !documentData}
|
||||||
onClick={onDownloadClick}
|
onClick={onDownloadClick}
|
||||||
|
loading={isLoading}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-5 w-5" />
|
<Download className="mr-2 h-5 w-5" />
|
||||||
|
|||||||
@@ -30,15 +30,21 @@ export default async function CompletedSigningPage({
|
|||||||
token,
|
token,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!document) {
|
if (!document || !document.documentData) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { documentData } = document;
|
||||||
|
|
||||||
const [fields, recipient] = await Promise.all([
|
const [fields, recipient] = await Promise.all([
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getRecipientByToken({ token }),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const recipientName =
|
const recipientName =
|
||||||
recipient.name ||
|
recipient.name ||
|
||||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||||
@@ -91,7 +97,7 @@ export default async function CompletedSigningPage({
|
|||||||
<DownloadButton
|
<DownloadButton
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
fileName={document.title}
|
fileName={document.title}
|
||||||
document={document.status === DocumentStatus.COMPLETED ? document.document : undefined}
|
documentData={documentData}
|
||||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
96
apps/web/src/app/(signing)/sign/[token]/email-field.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Recipient } from '@documenso/prisma/client';
|
||||||
|
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useRequiredSigningContext } from './provider';
|
||||||
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
|
export type EmailFieldProps = {
|
||||||
|
field: FieldWithSignature;
|
||||||
|
recipient: Recipient;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { email: providedEmail } = useRequiredSigningContext();
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
|
trpc.field.signFieldWithToken.useMutation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
|
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||||
|
|
||||||
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
|
const onSign = async () => {
|
||||||
|
try {
|
||||||
|
await signFieldWithToken({
|
||||||
|
token: recipient.token,
|
||||||
|
fieldId: field.id,
|
||||||
|
value: providedEmail ?? '',
|
||||||
|
isBase64: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while signing the document.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemove = async () => {
|
||||||
|
try {
|
||||||
|
await removeSignedFieldWithToken({
|
||||||
|
token: recipient.token,
|
||||||
|
fieldId: field.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while removing the signature.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
||||||
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!field.inserted && (
|
||||||
|
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Email</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
|
||||||
|
</SigningFieldContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -64,7 +64,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
<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 className="flex flex-1 flex-col gap-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="full-name">Full Name</Label>
|
<Label htmlFor="full-name">Full Name</Label>
|
||||||
@@ -87,9 +87,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
className="h-44 w-full"
|
className="h-44 w-full"
|
||||||
defaultValue={signature ?? undefined}
|
defaultValue={signature ?? undefined}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
console.log({
|
|
||||||
signpadValue: value,
|
|
||||||
});
|
|
||||||
setSignature(value);
|
setSignature(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -98,10 +95,10 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
<Button
|
<Button
|
||||||
type="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"
|
variant="secondary"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -109,8 +106,8 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
className="w-full"
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex-1"
|
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={!isComplete || isSubmitting}
|
disabled={!isComplete || isSubmitting}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
disabled={!localFullName}
|
disabled={!localFullName}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowFullNameModal(false);
|
setShowFullNameModal(false);
|
||||||
onSign('local');
|
void onSign('local');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign
|
Sign
|
||||||
|
|||||||
@@ -3,16 +3,19 @@ import { notFound } from 'next/navigation';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
import { DateField } from './date-field';
|
import { DateField } from './date-field';
|
||||||
|
import { EmailField } from './email-field';
|
||||||
import { SigningForm } from './form';
|
import { SigningForm } from './form';
|
||||||
import { NameField } from './name-field';
|
import { NameField } from './name-field';
|
||||||
import { SigningProvider } from './provider';
|
import { SigningProvider } from './provider';
|
||||||
@@ -34,18 +37,24 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
token,
|
token,
|
||||||
}).catch(() => null),
|
}).catch(() => null),
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getRecipientByToken({ token }),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
viewedDocument({ token }),
|
viewedDocument({ token }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!document) {
|
if (!document || !document.documentData || !recipient) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
const { documentData } = document;
|
||||||
|
|
||||||
|
const documentDataUrl = await getFile(documentData)
|
||||||
|
.then((buffer) => Buffer.from(buffer).toString('base64'))
|
||||||
|
.then((data) => `data:application/pdf;base64,${data}`);
|
||||||
|
|
||||||
|
const user = await getServerComponentSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningProvider email={recipient.email} fullName={recipient.name}>
|
<SigningProvider email={recipient.email} fullName={recipient.name} signature={user?.signature}>
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
{document.title}
|
{document.title}
|
||||||
@@ -57,13 +66,13 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<Card
|
||||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer document={documentUrl} />
|
<LazyPDFViewer document={documentDataUrl} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -84,6 +93,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
.with(FieldType.DATE, () => (
|
.with(FieldType.DATE, () => (
|
||||||
<DateField key={field.id} field={field} recipient={recipient} />
|
<DateField key={field.id} field={field} recipient={recipient} />
|
||||||
))
|
))
|
||||||
|
.with(FieldType.EMAIL, () => (
|
||||||
|
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
.otherwise(() => null),
|
.otherwise(() => null),
|
||||||
)}
|
)}
|
||||||
</ElementVisible>
|
</ElementVisible>
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export const useRequiredSigningContext = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface SigningProviderProps {
|
export interface SigningProviderProps {
|
||||||
fullName?: string;
|
fullName?: string | null;
|
||||||
email?: string;
|
email?: string | null;
|
||||||
signature?: string;
|
signature?: string | null;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,11 +63,6 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
|
|
||||||
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
||||||
try {
|
try {
|
||||||
console.log({
|
|
||||||
providedSignature,
|
|
||||||
localSignature,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!providedSignature && !localSignature) {
|
if (!providedSignature && !localSignature) {
|
||||||
setShowSignatureModal(true);
|
setShowSignatureModal(true);
|
||||||
return;
|
return;
|
||||||
@@ -141,6 +136,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
|
|
||||||
{state === 'signed-text' && (
|
{state === 'signed-text' && (
|
||||||
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
||||||
|
{/* This optional chaining is intentional, we don't want to move the check into the condition above */}
|
||||||
{signature?.typedSignature}
|
{signature?.typedSignature}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -182,7 +178,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
disabled={!localSignature}
|
disabled={!localSignature}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowSignatureModal(false);
|
setShowSignatureModal(false);
|
||||||
onSign('local');
|
void onSign('local');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign
|
Sign
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Suspense } from 'react';
|
|||||||
|
|
||||||
import { Caveat, Inter } from 'next/font/google';
|
import { Caveat, Inter } from 'next/font/google';
|
||||||
|
|
||||||
|
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
|
||||||
|
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||||
@@ -45,6 +47,8 @@ export const metadata = {
|
|||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const flags = await getServerComponentAllFlags();
|
const flags = await getServerComponentAllFlags();
|
||||||
|
|
||||||
|
const locale = getLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
@@ -63,6 +67,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<LocaleProvider locale={locale}>
|
||||||
<FeatureFlagProvider initialFlags={flags}>
|
<FeatureFlagProvider initialFlags={flags}>
|
||||||
<PlausibleProvider>
|
<PlausibleProvider>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
@@ -73,6 +78,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
</PlausibleProvider>
|
</PlausibleProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</FeatureFlagProvider>
|
</FeatureFlagProvider>
|
||||||
|
</LocaleProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 |
@@ -15,7 +15,7 @@ export type StackAvatarProps = {
|
|||||||
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
|
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => {
|
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
|
||||||
let classes = '';
|
let classes = '';
|
||||||
let zIndexClass = '';
|
let zIndexClass = '';
|
||||||
const firstClass = first ? '' : '-ml-3';
|
const firstClass = first ? '' : '-ml-3';
|
||||||
@@ -46,9 +46,9 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr
|
|||||||
className={`
|
className={`
|
||||||
${zIndexClass}
|
${zIndexClass}
|
||||||
${firstClass}
|
${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>
|
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { initials } from '@documenso/lib/client-only/recipient-initials';
|
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { Recipient } from '@documenso/prisma/client';
|
import { Recipient } from '@documenso/prisma/client';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -56,7 +56,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
first={true}
|
first={true}
|
||||||
key={recipient.id}
|
key={recipient.id}
|
||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={initials(recipient.name)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
first={true}
|
first={true}
|
||||||
key={recipient.id}
|
key={recipient.id}
|
||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={initials(recipient.name)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +90,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
first={true}
|
first={true}
|
||||||
key={recipient.id}
|
key={recipient.id}
|
||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={initials(recipient.name)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +107,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
first={true}
|
first={true}
|
||||||
key={recipient.id}
|
key={recipient.id}
|
||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={initials(recipient.name)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { initials } from '@documenso/lib/client-only/recipient-initials';
|
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { Recipient } from '@documenso/prisma/client';
|
import { Recipient } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { StackAvatar } from './stack-avatar';
|
import { StackAvatar } from './stack-avatar';
|
||||||
@@ -26,7 +26,7 @@ export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
|
|||||||
first={first}
|
first={first}
|
||||||
zIndex={String(zIndex - index * 10)}
|
zIndex={String(zIndex - index * 10)}
|
||||||
type={lastItemText && index === 4 ? 'unsigned' : getRecipientType(recipient)}
|
type={lastItemText && index === 4 ? 'unsigned' : getRecipientType(recipient)}
|
||||||
fallbackText={lastItemText ? lastItemText : initials(recipient.name)}
|
fallbackText={lastItemText ? lastItemText : recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||||
|
// const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('ml-8 hidden flex-1 gap-x-6 md:flex', className)} {...props}>
|
<div className={cn('ml-8 hidden flex-1 gap-x-6 md:flex', className)} {...props}>
|
||||||
{/* No Nav tabs while there is only one main page */}
|
{/* We have no other subpaths rn */}
|
||||||
{/* <Link
|
{/* <Link
|
||||||
href="/documents"
|
href="/documents"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Menu } from 'lucide-react';
|
|
||||||
|
|
||||||
import { User } from '@documenso/prisma/client';
|
import { User } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { Logo } from '~/components/branding/logo';
|
import { Logo } from '~/components/branding/logo';
|
||||||
|
|
||||||
@@ -20,10 +17,23 @@ export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Header = ({ className, user, ...props }: HeaderProps) => {
|
export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||||
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
setScrollY(window.scrollY);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScroll);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
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 border-b-transparent backdrop-blur duration-200',
|
||||||
|
scrollY > 5 && 'border-b-border',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -41,9 +51,9 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
|||||||
<div className="flex gap-x-4">
|
<div className="flex gap-x-4">
|
||||||
<ProfileDropdown user={user} />
|
<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" />
|
<Menu className="h-6 w-6" />
|
||||||
</Button>
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
|
UserCog,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
|
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { User } from '@documenso/prisma/client';
|
import { User } from '@documenso/prisma/client';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -35,24 +38,21 @@ export type ProfileDropdownProps = {
|
|||||||
|
|
||||||
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
const { getFlag } = useFeatureFlags();
|
const { getFlag } = useFeatureFlags();
|
||||||
|
const isUserAdmin = isAdmin(user);
|
||||||
|
|
||||||
const isBillingEnabled = getFlag('app_billing');
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
|
||||||
const initials =
|
const avatarFallback = user.name
|
||||||
user.name
|
? recipientInitials(user.name)
|
||||||
?.split(' ')
|
: user.email.slice(0, 1).toUpperCase();
|
||||||
.map((name: string) => name.slice(0, 1).toUpperCase())
|
|
||||||
.slice(0, 2)
|
|
||||||
.join('') ?? 'UK';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||||
<Avatar className="h-10 w-10">
|
<Avatar className="h-10 w-10">
|
||||||
<AvatarFallback>{initials}</AvatarFallback>
|
<AvatarFallback>{avatarFallback}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -60,6 +60,19 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||||
|
|
||||||
|
{isUserAdmin && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/admin" className="cursor-pointer">
|
||||||
|
<UserCog className="mr-2 h-4 w-4" />
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/profile" className="cursor-pointer">
|
<Link href="/settings/profile" className="cursor-pointer">
|
||||||
<LucideUser className="mr-2 h-4 w-4" />
|
<LucideUser className="mr-2 h-4 w-4" />
|
||||||
@@ -118,7 +131,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
signOut({
|
void signOut({
|
||||||
callbackUrl: '/',
|
callbackUrl: '/',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
|
<div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
|
||||||
<div className="flex items-start">
|
<div className="flex items-center">
|
||||||
{Icon && <Icon className="mr-2 h-4 w-4 text-slate-500" />}
|
{Icon && <Icon className="text-muted-foreground mr-2 h-4 w-4" />}
|
||||||
|
|
||||||
<h3 className="flex items-end text-sm font-medium text-slate-500">{title}</h3>
|
<h3 className="text-primary-forground flex items-end text-sm font-medium">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8">
|
<p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8">
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const PeriodSelector = () => {
|
|||||||
params.delete('period');
|
params.delete('period');
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push(`${pathname}?${params.toString()}`);
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||||
|
|
||||||
export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
|
export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
return ['', '7d', '14d', '30d'].includes(value as string);
|
return ['', '7d', '14d', '30d'].includes(value as string);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Github } from 'lucide-react';
|
|
||||||
import { usePlausible } from 'next-plausible';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
export type CalloutProps = {
|
|
||||||
starCount?: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Callout = ({ starCount }: CalloutProps) => {
|
|
||||||
const event = usePlausible();
|
|
||||||
|
|
||||||
const onSignUpClick = () => {
|
|
||||||
const el = document.getElementById('email');
|
|
||||||
|
|
||||||
if (el) {
|
|
||||||
const { top } = el.getBoundingClientRect();
|
|
||||||
|
|
||||||
window.scrollTo({
|
|
||||||
top: top - 120,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
el.focus();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
|
||||||
onClick={onSignUpClick}
|
|
||||||
>
|
|
||||||
Get the Community Plan
|
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
|
||||||
$30/mo. forever!
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://github.com/documenso/documenso"
|
|
||||||
target="_blank"
|
|
||||||
onClick={() => event('view-github')}
|
|
||||||
>
|
|
||||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
|
||||||
<Github className="mr-2 h-5 w-5" />
|
|
||||||
Star on Github
|
|
||||||
{starCount && starCount > 0 && (
|
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
|
||||||
{starCount.toLocaleString('en-US')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Info, Loader } from 'lucide-react';
|
|
||||||
import { usePlausible } from 'next-plausible';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
|
||||||
|
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
|
||||||
|
|
||||||
export const ZClaimPlanDialogFormSchema = z.object({
|
|
||||||
name: z.string().min(3),
|
|
||||||
email: z.string().email(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TClaimPlanDialogFormSchema = z.infer<typeof ZClaimPlanDialogFormSchema>;
|
|
||||||
|
|
||||||
export type ClaimPlanDialogProps = {
|
|
||||||
className?: string;
|
|
||||||
planId: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
|
|
||||||
const params = useSearchParams();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const event = usePlausible();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<TClaimPlanDialogFormSchema>({
|
|
||||||
mode: 'onBlur',
|
|
||||||
defaultValues: {
|
|
||||||
name: params?.get('name') ?? '',
|
|
||||||
email: params?.get('email') ?? '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZClaimPlanDialogFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
|
|
||||||
try {
|
|
||||||
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
const [redirectUrl] = await Promise.all([
|
|
||||||
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
|
|
||||||
delay,
|
|
||||||
]);
|
|
||||||
|
|
||||||
event('claim-plan-pricing');
|
|
||||||
|
|
||||||
window.location.href = redirectUrl;
|
|
||||||
} catch (error) {
|
|
||||||
event('claim-plan-failed');
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: error instanceof Error ? error.message : 'Please try again later.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Claim your plan</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
We're almost there! Please enter your email address and name to claim your plan.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form
|
|
||||||
className={cn('flex flex-col gap-y-4', className)}
|
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
|
||||||
>
|
|
||||||
{params?.get('cancelled') === 'true' && (
|
|
||||||
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Info className="h-5 w-5 text-yellow-400" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<p className="text-sm leading-5 text-yellow-700">
|
|
||||||
You have cancelled the payment process. If you didn't mean to do this, please
|
|
||||||
try again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-slate-500">Name</Label>
|
|
||||||
|
|
||||||
<Input type="text" className="mt-2" {...register('name')} autoFocus />
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-1" error={errors.name} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-slate-500">Email</Label>
|
|
||||||
|
|
||||||
<Input type="email" className="mt-2" {...register('email')} />
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-1" error={errors.email} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" size="lg" disabled={isSubmitting}>
|
|
||||||
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
Claim the Community Plan ({/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
|
|
||||||
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
|
||||||
? 'Monthly'
|
|
||||||
: 'Yearly'}
|
|
||||||
)
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
import cardBeautifulFigure from '~/assets/card-beautiful-figure.png';
|
|
||||||
import cardFastFigure from '~/assets/card-fast-figure.png';
|
|
||||||
import cardSmartFigure from '~/assets/card-smart-figure.png';
|
|
||||||
|
|
||||||
export type FasterSmarterBeautifulBentoProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const FasterSmarterBeautifulBento = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: FasterSmarterBeautifulBentoProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn('relative', className)} {...props}>
|
|
||||||
<div className="absolute inset-0 -z-10 flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src={backgroundPattern}
|
|
||||||
alt="background pattern"
|
|
||||||
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
|
||||||
A 10x better signing experience.
|
|
||||||
<span className="block md:mt-0">Faster, smarter and more beautiful.</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
|
||||||
<Card className="col-span-2" degrees={45} gradient>
|
|
||||||
<CardContent className="grid grid-cols-12 gap-8 overflow-hidden p-6 lg:aspect-[2.5/1]">
|
|
||||||
<p className="col-span-12 leading-relaxed text-[#555E67] lg:col-span-6">
|
|
||||||
<strong className="block">Fast.</strong>
|
|
||||||
When it comes to sending or receiving a contract, you can count on lightning-fast
|
|
||||||
speeds.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
|
|
||||||
<Image src={cardFastFigure} alt="its fast" className="max-w-[80%] lg:max-w-none" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
|
||||||
<strong className="block">Beautiful.</strong>
|
|
||||||
Because signing should be celebrated. That’s why we care about the smallest detail in
|
|
||||||
our product.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Image src={cardBeautifulFigure} alt="its fast" className="w-full max-w-xs" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
|
||||||
<strong className="block">Smart.</strong>
|
|
||||||
Our custom templates come with smart rules that can help you save time and energy.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Image src={cardSmartFigure} alt="its fast" className="w-full max-w-[16rem]" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Github, Slack, Twitter } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn('border-t py-12', className)} {...props}>
|
|
||||||
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
|
||||||
<div>
|
|
||||||
<Link href="/">
|
|
||||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={0}></Image>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-4 text-[#8D8D8D]">
|
|
||||||
<Link
|
|
||||||
href="https://twitter.com/documenso"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<Twitter className="h-6 w-6" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://github.com/documenso/documenso"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<Github className="h-6 w-6" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://documenso.slack.com"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<Slack className="h-6 w-6" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
|
||||||
<Link
|
|
||||||
href="/pricing"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Pricing
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://status.documenso.com"
|
|
||||||
target="_blank"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Status
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="mailto:support@documenso.com"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Support
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* <Link
|
|
||||||
href="/privacy"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Privacy
|
|
||||||
</Link> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mx-auto mt-4 w-full max-w-screen-xl px-8 md:mt-12 lg:mt-24">
|
|
||||||
<p className="text-sm text-[#8D8D8D]">
|
|
||||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type HeaderProps = HTMLAttributes<HTMLElement>;
|
|
||||||
|
|
||||||
export const Header = ({ className, ...props }: HeaderProps) => {
|
|
||||||
return (
|
|
||||||
<header className={cn('flex items-center justify-between', className)} {...props}>
|
|
||||||
<Link href="/">
|
|
||||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={0}></Image>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-x-6">
|
|
||||||
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
|
||||||
Pricing
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://app.documenso.com/login"
|
|
||||||
target="_blank"
|
|
||||||
className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Variants, motion } from 'framer-motion';
|
|
||||||
import { Github } from 'lucide-react';
|
|
||||||
import { usePlausible } from 'next-plausible';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
|
|
||||||
import { Widget } from './widget';
|
|
||||||
|
|
||||||
export type HeroProps = {
|
|
||||||
className?: string;
|
|
||||||
starCount?: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
const BackgroundPatternVariants: Variants = {
|
|
||||||
initial: {
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
|
|
||||||
animate: {
|
|
||||||
opacity: 1,
|
|
||||||
|
|
||||||
transition: {
|
|
||||||
delay: 1,
|
|
||||||
duration: 1.2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const HeroTitleVariants: Variants = {
|
|
||||||
initial: {
|
|
||||||
opacity: 0,
|
|
||||||
y: 60,
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Hero = ({ className, starCount, ...props }: HeroProps) => {
|
|
||||||
const event = usePlausible();
|
|
||||||
|
|
||||||
const onSignUpClick = () => {
|
|
||||||
const el = document.getElementById('email');
|
|
||||||
|
|
||||||
if (el) {
|
|
||||||
const { top } = el.getBoundingClientRect();
|
|
||||||
|
|
||||||
window.scrollTo({
|
|
||||||
top: top - 120,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
el.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div className={cn('relative', className)} {...props}>
|
|
||||||
<div className="absolute -inset-24 -z-10">
|
|
||||||
<motion.div
|
|
||||||
className="flex h-full w-full origin-top-right items-center justify-center"
|
|
||||||
variants={BackgroundPatternVariants}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={backgroundPattern}
|
|
||||||
alt="background pattern"
|
|
||||||
className="-mr-[50vw] -mt-[15vh] h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<motion.h2
|
|
||||||
variants={HeroTitleVariants}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
className="text-center text-4xl font-bold leading-tight tracking-tight lg:text-[64px]"
|
|
||||||
>
|
|
||||||
Document signing,
|
|
||||||
<span className="block" /> finally open source.
|
|
||||||
</motion.h2>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
variants={HeroTitleVariants}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
|
||||||
onClick={onSignUpClick}
|
|
||||||
>
|
|
||||||
Get the Community Plan
|
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
|
||||||
$30/mo. forever!
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
|
|
||||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
|
||||||
<Github className="mr-2 h-5 w-5" />
|
|
||||||
Star on Github
|
|
||||||
{starCount && starCount > 0 && (
|
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
|
||||||
{starCount.toLocaleString('en-US')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-6">
|
|
||||||
<motion.div
|
|
||||||
variants={HeroTitleVariants}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
className="mt-8 flex flex-col items-center justify-center gap-x-6 gap-y-4"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href="https://www.producthunt.com/posts/documenso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-documenso"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=395047&theme=light&period=daily"
|
|
||||||
alt="Documenso - The open source DocuSign alternative | Product Hunt"
|
|
||||||
style={{ width: '250px', height: '54px' }}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="mt-12"
|
|
||||||
variants={{
|
|
||||||
initial: {
|
|
||||||
scale: 0.2,
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
scale: 1,
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
ease: 'easeInOut',
|
|
||||||
delay: 0.5,
|
|
||||||
duration: 0.8,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
>
|
|
||||||
<Widget className="mt-12">
|
|
||||||
<strong>Documenso Supporter Pledge</strong>
|
|
||||||
<p className="w-full max-w-[70ch]">
|
|
||||||
Our mission is to create an open signing infrastructure that empowers the world,
|
|
||||||
enabling businesses to embrace openness, cooperation, and transparency. We believe
|
|
||||||
that signing, as a fundamental act, should embody these values. By offering an
|
|
||||||
open-source signing solution, we aim to make document signing accessible, transparent,
|
|
||||||
and trustworthy.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="w-full max-w-[70ch]">
|
|
||||||
Through our platform, called Documenso, we strive to earn your trust by allowing
|
|
||||||
self-hosting and providing complete visibility into its inner workings. We value
|
|
||||||
inclusivity and foster an environment where diverse perspectives and contributions are
|
|
||||||
welcomed, even though we may not implement them all.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="w-full max-w-[70ch]">
|
|
||||||
At Documenso, we envision a web-enabled future for business and contracts, and we are
|
|
||||||
committed to being the leading provider of open signing infrastructure. By combining
|
|
||||||
exceptional product design with open-source principles, we aim to deliver a robust and
|
|
||||||
well-designed application that exceeds your expectations.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="w-full max-w-[70ch]">
|
|
||||||
We understand that exceptional products are born from exceptional communities, and we
|
|
||||||
invite you to join our open-source community. Your contributions, whether technical or
|
|
||||||
non-technical, will help shape the future of signing. Together, we can create a better
|
|
||||||
future for everyone.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="w-full max-w-[70ch]">
|
|
||||||
Today we invite you to join us on this journey: By signing this mission statement you
|
|
||||||
signal your support of Documenso's mission{' '}
|
|
||||||
<span className="bg-primary text-black">
|
|
||||||
(in a non-legally binding, but heartfelt way)
|
|
||||||
</span>{' '}
|
|
||||||
and lock in the early supporter plan for forever, including everything we build this
|
|
||||||
year.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex h-24 items-center">
|
|
||||||
<p className={cn('text-5xl [font-family:var(--font-caveat)]')}>Timur & Lucas</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<strong>Timur Ercan & Lucas Smith</strong>
|
|
||||||
<p className="mt-1">Co-Founders, Documenso</p>
|
|
||||||
</div>
|
|
||||||
</Widget>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
import cardBuildFigure from '~/assets/card-build-figure.png';
|
|
||||||
import cardOpenFigure from '~/assets/card-open-figure.png';
|
|
||||||
import cardTemplateFigure from '~/assets/card-template-figure.png';
|
|
||||||
|
|
||||||
export type OpenBuildTemplateBentoProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplateBentoProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn('relative', className)} {...props}>
|
|
||||||
<div className="absolute inset-0 -z-10 flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src={backgroundPattern}
|
|
||||||
alt="background pattern"
|
|
||||||
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
|
||||||
Truly your own.
|
|
||||||
<span className="block md:mt-0">Customise and expand.</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
|
||||||
<Card className="col-span-2" degrees={45} gradient>
|
|
||||||
<CardContent className="grid grid-cols-12 gap-8 overflow-hidden p-6 lg:aspect-[2.5/1]">
|
|
||||||
<p className="col-span-12 leading-relaxed text-[#555E67] lg:col-span-6">
|
|
||||||
<strong className="block">Open Source or Hosted.</strong>
|
|
||||||
It’s up to you. Either clone our repository or rely on our easy to use hosting
|
|
||||||
solution.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
|
|
||||||
<Image src={cardOpenFigure} alt="its fast" className="max-w-[80%] lg:max-w-full" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
|
||||||
<strong className="block">Build on top.</strong>
|
|
||||||
Make it your own through advanced customization and adjustability.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Image src={cardBuildFigure} alt="its fast" className="w-full max-w-xs" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
|
||||||
<strong className="block">Template Store (Soon).</strong>
|
|
||||||
Choose a template from the community app store. Or submit your own template for others
|
|
||||||
to use.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Image src={cardTemplateFigure} alt="its fast" className="w-full max-w-sm" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
|
||||||
|
|
||||||
export type PasswordRevealProps = {
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PasswordReveal = ({ password }: PasswordRevealProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [, copy] = useCopyToClipboard();
|
|
||||||
|
|
||||||
const onCopyClick = () => {
|
|
||||||
copy(password).then(() => {
|
|
||||||
toast({
|
|
||||||
title: 'Copied to clipboard',
|
|
||||||
description: 'Your password has been copied to your clipboard.',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="px-2 blur-sm hover:opacity-50 hover:blur-none"
|
|
||||||
onClick={onCopyClick}
|
|
||||||
>
|
|
||||||
{password}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { HTMLAttributes, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { usePlausible } from 'next-plausible';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { ClaimPlanDialog } from './claim-plan-dialog';
|
|
||||||
|
|
||||||
export type PricingTableProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar';
|
|
||||||
|
|
||||||
export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|
||||||
const params = useSearchParams();
|
|
||||||
const event = usePlausible();
|
|
||||||
|
|
||||||
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() =>
|
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
|
|
||||||
? 'YEARLY'
|
|
||||||
: 'MONTHLY',
|
|
||||||
);
|
|
||||||
|
|
||||||
const planId = useMemo(() => {
|
|
||||||
if (period === 'MONTHLY') {
|
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID;
|
|
||||||
}, [period]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('', className)} {...props}>
|
|
||||||
<div className="flex items-center justify-center gap-x-6">
|
|
||||||
<AnimatePresence>
|
|
||||||
<motion.button
|
|
||||||
key="MONTHLY"
|
|
||||||
className={cn('relative flex items-center gap-x-2.5 px-1 py-2.5 text-[#727272]', {
|
|
||||||
'text-slate-900': period === 'MONTHLY',
|
|
||||||
'hover:text-slate-900/80': period !== 'MONTHLY',
|
|
||||||
})}
|
|
||||||
onClick={() => setPeriod('MONTHLY')}
|
|
||||||
>
|
|
||||||
Monthly
|
|
||||||
{period === 'MONTHLY' && (
|
|
||||||
<motion.div
|
|
||||||
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
|
|
||||||
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</motion.button>
|
|
||||||
|
|
||||||
<motion.button
|
|
||||||
key="YEARLY"
|
|
||||||
className={cn('relative flex items-center gap-x-2.5 px-1 py-2.5 text-[#727272]', {
|
|
||||||
'text-slate-900': period === 'YEARLY',
|
|
||||||
'hover:text-slate-900/80': period !== 'YEARLY',
|
|
||||||
})}
|
|
||||||
onClick={() => setPeriod('YEARLY')}
|
|
||||||
>
|
|
||||||
Yearly
|
|
||||||
<div className="block rounded-full bg-slate-200 px-2 py-0.5 text-xs text-slate-700">
|
|
||||||
Save $60
|
|
||||||
</div>
|
|
||||||
{period === 'YEARLY' && (
|
|
||||||
<motion.div
|
|
||||||
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
|
|
||||||
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</motion.button>
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 grid grid-cols-1 gap-x-6 gap-y-12 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<div
|
|
||||||
data-plan="self-hosted"
|
|
||||||
className="flex flex-col items-center justify-center rounded-lg border bg-white px-8 py-12 shadow-lg shadow-slate-900/5"
|
|
||||||
>
|
|
||||||
<p className="text-4xl font-medium text-slate-900">Self Hosted</p>
|
|
||||||
<p className="text-primary mt-2.5 text-xl font-medium">Free</p>
|
|
||||||
|
|
||||||
<p className="mt-4 max-w-[30ch] text-center text-slate-900">
|
|
||||||
For small teams and individuals who need a simple solution
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button className="mt-6 rounded-full text-base">
|
|
||||||
<Link
|
|
||||||
href="https://github.com/documenso/documenso"
|
|
||||||
target="_blank"
|
|
||||||
onClick={() => event('view-github')}
|
|
||||||
>
|
|
||||||
View on Github
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
|
||||||
<p className="py-4 font-medium text-slate-900">Host your own instance</p>
|
|
||||||
<p className="py-4 text-slate-900">Full Control</p>
|
|
||||||
<p className="py-4 text-slate-900">Customizability</p>
|
|
||||||
<p className="py-4 text-slate-900">Docker Ready</p>
|
|
||||||
<p className="py-4 text-slate-900">Community Support</p>
|
|
||||||
<p className="py-4 text-slate-900">Free, Forever</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
data-plan="community"
|
|
||||||
className="border-primary flex flex-col items-center justify-center rounded-lg border-2 bg-white px-8 py-12 shadow-[0px_0px_0px_4px_#E3E3E380] shadow-slate-900/5"
|
|
||||||
>
|
|
||||||
<p className="text-4xl font-medium text-slate-900">Community</p>
|
|
||||||
<div className="text-primary mt-2.5 text-xl font-medium">
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{period === 'MONTHLY' && <motion.div layoutId="pricing">$30</motion.div>}
|
|
||||||
{period === 'YEARLY' && <motion.div layoutId="pricing">$300</motion.div>}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-4 max-w-[30ch] text-center text-slate-900">
|
|
||||||
For fast-growing companies that aim to scale across multiple teams.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ClaimPlanDialog planId={planId}>
|
|
||||||
<Button className="mt-6 rounded-full text-base">Signup Now</Button>
|
|
||||||
</ClaimPlanDialog>
|
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
|
||||||
<p className="py-4 font-medium text-slate-900">Documenso Early Adopter Deal:</p>
|
|
||||||
<p className="py-4 text-slate-900">Join the movement</p>
|
|
||||||
<p className="py-4 text-slate-900">Simple signing solution</p>
|
|
||||||
<p className="py-4 text-slate-900">Email and Slack assistance</p>
|
|
||||||
<p className="py-4 text-slate-900">
|
|
||||||
<strong>Includes all upcoming features</strong>
|
|
||||||
</p>
|
|
||||||
<p className="py-4 text-slate-900">Fixed, straightforward pricing</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
data-plan="enterprise"
|
|
||||||
className="flex flex-col items-center justify-center rounded-lg border bg-white px-8 py-12 shadow-lg shadow-slate-900/5"
|
|
||||||
>
|
|
||||||
<p className="text-4xl font-medium text-slate-900">Enterprise</p>
|
|
||||||
<p className="text-primary mt-2.5 text-xl font-medium">Pricing on request</p>
|
|
||||||
|
|
||||||
<p className="mt-4 max-w-[30ch] text-center text-slate-900">
|
|
||||||
For large organizations that need extra flexibility and control.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://dub.sh/enterprise"
|
|
||||||
target="_blank"
|
|
||||||
className="mt-6"
|
|
||||||
onClick={() => event('enterprise-contact')}
|
|
||||||
>
|
|
||||||
<Button className="rounded-full text-base">Contact Us</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
|
||||||
<p className="py-4 font-medium text-slate-900">Everything in Community, plus:</p>
|
|
||||||
<p className="py-4 text-slate-900">Custom Subdomain</p>
|
|
||||||
<p className="py-4 text-slate-900">Compliance Check</p>
|
|
||||||
<p className="py-4 text-slate-900">Guaranteed Uptime</p>
|
|
||||||
<p className="py-4 text-slate-900">Reporting & Analysis</p>
|
|
||||||
<p className="py-4 text-slate-900">24/7 Support</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
import cardConnectionsFigure from '~/assets/card-connections-figure.png';
|
|
||||||
import cardPaidFigure from '~/assets/card-paid-figure.png';
|
|
||||||
import cardSharingFigure from '~/assets/card-sharing-figure.png';
|
|
||||||
import cardWidgetFigure from '~/assets/card-widget-figure.png';
|
|
||||||
|
|
||||||
export type ShareConnectPaidWidgetBentoProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const ShareConnectPaidWidgetBento = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ShareConnectPaidWidgetBentoProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn('relative', className)} {...props}>
|
|
||||||
<div className="absolute inset-0 -z-10 flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src={backgroundPattern}
|
|
||||||
alt="background pattern"
|
|
||||||
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
|
||||||
Integrates with all your favourite tools.
|
|
||||||
<span className="block md:mt-0">Send, connect, receive and embed everywhere.</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
|
||||||
<Card className="col-span-2 lg:col-span-1" degrees={120} gradient>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
|
||||||
<strong className="block">Easy Sharing (Soon).</strong>
|
|
||||||
Receive your personal link to share with everyone you care about.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Image src={cardSharingFigure} alt="its fast" className="w-full max-w-xs" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
|
||||||
<strong className="block">Connections (Soon).</strong>
|
|
||||||
Create connections and automations with Zapier and more to integrate with your
|
|
||||||
favorite tools.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Image src={cardConnectionsFigure} alt="its fast" className="w-full max-w-sm" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
|
||||||
<strong className="block">Get paid (Soon).</strong>
|
|
||||||
Integrated payments with stripe so you don’t have to worry about getting paid.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Image src={cardPaidFigure} alt="its fast" className="w-full max-w-[14rem]" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
|
||||||
<strong className="block">React Widget (Soon).</strong>
|
|
||||||
Easily embed Documenso into your product. Simply copy and paste our react widget into
|
|
||||||
your application.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Image src={cardWidgetFigure} alt="its fast" className="w-full max-w-xs" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { HTMLAttributes, KeyboardEvent, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
import { usePlausible } from 'next-plausible';
|
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
|
||||||
|
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
|
||||||
|
|
||||||
const ZWidgetFormSchema = z
|
|
||||||
.object({
|
|
||||||
email: z.string().email({ message: 'Please enter a valid email address.' }),
|
|
||||||
name: z.string().min(3, { message: 'Please enter a valid name.' }),
|
|
||||||
})
|
|
||||||
.and(
|
|
||||||
z.union([
|
|
||||||
z.object({
|
|
||||||
signatureDataUrl: z.string().min(1),
|
|
||||||
signatureText: z.null().or(z.string().max(0)),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
signatureDataUrl: z.null().or(z.string().max(0)),
|
|
||||||
signatureText: z.string().min(1),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
|
|
||||||
|
|
||||||
export type WidgetProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const event = usePlausible();
|
|
||||||
|
|
||||||
const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL');
|
|
||||||
const [showSigningDialog, setShowSigningDialog] = useState(false);
|
|
||||||
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
setValue,
|
|
||||||
trigger,
|
|
||||||
watch,
|
|
||||||
formState: { errors, isSubmitting, isValid },
|
|
||||||
} = useForm<TWidgetFormSchema>({
|
|
||||||
mode: 'onChange',
|
|
||||||
defaultValues: {
|
|
||||||
email: '',
|
|
||||||
name: '',
|
|
||||||
signatureDataUrl: null,
|
|
||||||
signatureText: '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZWidgetFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const signatureDataUrl = watch('signatureDataUrl');
|
|
||||||
const signatureText = watch('signatureText');
|
|
||||||
|
|
||||||
const stepsRemaining = useMemo(() => {
|
|
||||||
if (step === 'NAME') {
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === 'SIGN') {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 3;
|
|
||||||
}, [step]);
|
|
||||||
|
|
||||||
const onNextStepClick = () => {
|
|
||||||
if (step === 'EMAIL') {
|
|
||||||
setStep('NAME');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
document.querySelector<HTMLElement>('#name')?.focus();
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === 'NAME') {
|
|
||||||
setStep('SIGN');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
document.querySelector<HTMLElement>('#signatureText')?.focus();
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onEnterPress = (callback: () => void) => {
|
|
||||||
return (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSignatureConfirmClick = () => {
|
|
||||||
setValue('signatureDataUrl', draftSignatureDataUrl);
|
|
||||||
setValue('signatureText', '');
|
|
||||||
|
|
||||||
trigger('signatureDataUrl');
|
|
||||||
setShowSigningDialog(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFormSubmit = async ({
|
|
||||||
email,
|
|
||||||
name,
|
|
||||||
signatureDataUrl,
|
|
||||||
signatureText,
|
|
||||||
}: TWidgetFormSchema) => {
|
|
||||||
try {
|
|
||||||
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
|
||||||
|
|
||||||
const claimPlanInput = signatureDataUrl
|
|
||||||
? {
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
planId,
|
|
||||||
signatureDataUrl: signatureDataUrl!,
|
|
||||||
signatureText: null,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
planId,
|
|
||||||
signatureDataUrl: null,
|
|
||||||
signatureText: signatureText!,
|
|
||||||
};
|
|
||||||
|
|
||||||
const [result] = await Promise.all([claimPlan(claimPlanInput), delay]);
|
|
||||||
|
|
||||||
event('claim-plan-widget');
|
|
||||||
|
|
||||||
window.location.href = result;
|
|
||||||
} catch (error) {
|
|
||||||
event('claim-plan-failed');
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: error instanceof Error ? error.message : 'Please try again later.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card
|
|
||||||
className={cn('mx-auto w-full max-w-4xl rounded-3xl before:rounded-3xl', className)}
|
|
||||||
gradient
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-12 gap-y-8 overflow-hidden p-2 lg:gap-x-8">
|
|
||||||
<div className="col-span-12 flex flex-col gap-y-4 p-4 text-xs leading-relaxed text-[#727272] lg:col-span-7">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
className="col-span-12 flex flex-col rounded-2xl bg-[#F7F7F7] p-6 lg:col-span-5"
|
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
|
||||||
>
|
|
||||||
<h3 className="text-2xl font-semibold">Sign up for the community plan</h3>
|
|
||||||
<p className="mt-2 text-xs text-[#AFAFAF]">
|
|
||||||
with Timur Ercan & Lucas Smith from Documenso
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="mb-6 mt-4" />
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
<motion.div key="email">
|
|
||||||
<label htmlFor="email" className="text-lg font-semibold text-slate-900 lg:text-xl">
|
|
||||||
What’s your email?
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="relative mt-2">
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder=""
|
|
||||||
className="w-full bg-white pr-16"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onKeyDown={(e) =>
|
|
||||||
field.value !== '' &&
|
|
||||||
!errors.email?.message &&
|
|
||||||
onEnterPress(onNextStepClick)(e)
|
|
||||||
}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute inset-y-0 right-0 p-1.5">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="bg-primary h-full w-14 rounded"
|
|
||||||
disabled={!field.value || !!errors.email?.message}
|
|
||||||
onClick={() => onNextStepClick()}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormErrorMessage error={errors.email} className="mt-1" />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{(step === 'NAME' || step === 'SIGN') && (
|
|
||||||
<motion.div
|
|
||||||
key="name"
|
|
||||||
className="mt-4"
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
transform: 'translateX(0)',
|
|
||||||
}}
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
transform: 'translateX(-25%)',
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
transform: 'translateX(25%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label htmlFor="name" className="text-lg font-semibold text-slate-900 lg:text-xl">
|
|
||||||
and your name?
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="relative mt-2">
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
type="text"
|
|
||||||
placeholder=""
|
|
||||||
className="w-full bg-white pr-16"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onKeyDown={(e) =>
|
|
||||||
field.value !== '' &&
|
|
||||||
!errors.name?.message &&
|
|
||||||
onEnterPress(onNextStepClick)(e)
|
|
||||||
}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute inset-y-0 right-0 p-1.5">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="bg-primary h-full w-14 rounded"
|
|
||||||
disabled={!field.value || !!errors.name?.message}
|
|
||||||
onClick={() => onNextStepClick()}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormErrorMessage error={errors.name} className="mt-1" />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<div className="mt-12 flex-1" />
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-xs text-[#AFAFAF]">{stepsRemaining} step(s) until signed</p>
|
|
||||||
<p className="block text-xs text-[#AFAFAF] md:hidden">Minimise contract</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mt-2.5 h-[2px] w-full bg-[#E9E9E9]">
|
|
||||||
<div
|
|
||||||
className={cn('bg-primary/60 absolute inset-y-0 left-0 duration-200', {
|
|
||||||
'w-1/3': stepsRemaining === 3,
|
|
||||||
'w-2/3': stepsRemaining === 2,
|
|
||||||
'w-11/12': stepsRemaining === 1,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card id="signature" className="mt-4" degrees={-140} gradient>
|
|
||||||
<CardContent
|
|
||||||
role="button"
|
|
||||||
className="relative cursor-pointer pt-6"
|
|
||||||
onClick={() => setShowSigningDialog(true)}
|
|
||||||
>
|
|
||||||
<div className="flex h-28 items-center justify-center pb-6">
|
|
||||||
{!signatureText && signatureDataUrl && (
|
|
||||||
<img src={signatureDataUrl} alt="user signature" className="h-full" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{signatureText && (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-4xl font-semibold text-slate-900 [font-family:var(--font-caveat)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{signatureText}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="absolute inset-x-0 bottom-0 flex cursor-auto items-center justify-between px-4 pb-2"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="signatureText"
|
|
||||||
className="border-none p-0 text-sm text-slate-700 placeholder:text-[#D6D6D6] focus-visible:ring-0"
|
|
||||||
placeholder="Draw or type name here"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
{...register('signatureText', {
|
|
||||||
onChange: (e) => {
|
|
||||||
if (e.target.value !== '') {
|
|
||||||
setValue('signatureDataUrl', null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="h-8 disabled:bg-[#ECEEED] disabled:text-[#C6C6C6] disabled:hover:bg-[#ECEEED]"
|
|
||||||
disabled={!isValid || isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
Sign
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Dialog open={showSigningDialog} onOpenChange={setShowSigningDialog}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add your signature</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
By signing you signal your support of Documenso's mission in a <br></br>
|
|
||||||
<strong>non-legally binding, but heartfelt way</strong>. <br></br>
|
|
||||||
<br></br>You also unlock the option to purchase the early supporter plan including
|
|
||||||
everything we build this year for fixed price.
|
|
||||||
</DialogDescription>
|
|
||||||
|
|
||||||
<SignaturePad
|
|
||||||
className="aspect-video w-full rounded-md border"
|
|
||||||
onChange={setDraftSignatureDataUrl}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={() => setShowSigningDialog(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button onClick={() => onSignatureConfirmClick()}>Confirm</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -3,16 +3,17 @@ import { HTMLAttributes } from 'react';
|
|||||||
import { CheckCircle2, Clock, File } from 'lucide-react';
|
import { CheckCircle2, Clock, File } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react/dist/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';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
type FriendlyStatus = {
|
type FriendlyStatus = {
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon?: LucideIcon;
|
||||||
color: string;
|
color: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = {
|
const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
|
||||||
PENDING: {
|
PENDING: {
|
||||||
label: 'Pending',
|
label: 'Pending',
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
@@ -28,10 +29,19 @@ const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = {
|
|||||||
icon: File,
|
icon: File,
|
||||||
color: 'text-yellow-500',
|
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> & {
|
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
|
||||||
status: InternalDocumentStatus;
|
status: ExtendedDocumentStatus;
|
||||||
inheritColor?: boolean;
|
inheritColor?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,11 +55,13 @@ export const DocumentStatus = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={cn('flex items-center', className)} {...props}>
|
<span className={cn('flex items-center', className)} {...props}>
|
||||||
|
{Icon && (
|
||||||
<Icon
|
<Icon
|
||||||
className={cn('mr-2 inline-block h-4 w-4', {
|
className={cn('mr-2 inline-block h-4 w-4', {
|
||||||
[color]: !inheritColor,
|
[color]: !inheritColor,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,16 +2,31 @@
|
|||||||
|
|
||||||
import { HTMLAttributes, useEffect, useState } from 'react';
|
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { DateTime, DateTimeFormatOptions } from 'luxon';
|
||||||
|
|
||||||
|
import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
||||||
|
|
||||||
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
||||||
date: string | number | Date;
|
date: string | number | Date;
|
||||||
|
format?: DateTimeFormatOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LocaleDate = ({ className, date, ...props }: LocaleDateProps) => {
|
/**
|
||||||
const [localeDate, setLocaleDate] = useState(() => new Date(date).toISOString());
|
* Formats the date based on the user locale.
|
||||||
|
*
|
||||||
|
* Will use the estimated locale from the user headers on SSR, then will use
|
||||||
|
* the client browser locale once mounted.
|
||||||
|
*/
|
||||||
|
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
|
||||||
|
const { locale } = useLocale();
|
||||||
|
|
||||||
|
const [localeDate, setLocaleDate] = useState(() =>
|
||||||
|
DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocaleDate(new Date(date).toLocaleString());
|
setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format));
|
||||||
}, [date]);
|
}, [date, format]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={className} {...props}>
|
<span className={className} {...props}>
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import { FormErrorMessage } from '../form/form-error-message';
|
|||||||
|
|
||||||
export const ZPasswordFormSchema = z
|
export const ZPasswordFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
password: z.string().min(6),
|
password: z.string().min(6).max(72),
|
||||||
repeatedPassword: z.string().min(6),
|
repeatedPassword: z.string().min(6).max(72),
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.repeatedPassword, {
|
.refine((data) => data.password === data.repeatedPassword, {
|
||||||
message: 'Passwords do not match',
|
message: 'Passwords do not match',
|
||||||
@@ -39,6 +39,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
reset,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<TPasswordFormSchema>({
|
} = useForm<TPasswordFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
@@ -56,6 +57,8 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
reset();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Password updated',
|
title: 'Password updated',
|
||||||
description: 'Your password has been updated successfully.',
|
description: 'Your password has been updated successfully.',
|
||||||
@@ -73,7 +76,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
title: 'An unknown error occurred',
|
title: 'An unknown error occurred',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description:
|
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
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
minLength={6}
|
||||||
|
maxLength={72}
|
||||||
|
autoComplete="new-password"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
{...register('password')}
|
{...register('password')}
|
||||||
/>
|
/>
|
||||||
@@ -107,6 +113,9 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
<Input
|
<Input
|
||||||
id="repeated-password"
|
id="repeated-password"
|
||||||
type="password"
|
type="password"
|
||||||
|
minLength={6}
|
||||||
|
maxLength={72}
|
||||||
|
autoComplete="new-password"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
{...register('repeatedPassword')}
|
{...register('repeatedPassword')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { FormErrorMessage } from '../form/form-error-message';
|
|||||||
|
|
||||||
export const ZProfileFormSchema = z.object({
|
export const ZProfileFormSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
signature: z.string().min(1),
|
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TProfileFormSchema = z.infer<typeof ZProfileFormSchema>;
|
export type TProfileFormSchema = z.infer<typeof ZProfileFormSchema>;
|
||||||
@@ -44,7 +44,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
} = useForm<TProfileFormSchema>({
|
} = useForm<TProfileFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
name: user.name ?? '',
|
name: user.name ?? '',
|
||||||
signature: '',
|
signature: user.signature || '',
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZProfileFormSchema),
|
resolver: zodResolver(ZProfileFormSchema),
|
||||||
});
|
});
|
||||||
@@ -118,10 +118,12 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
render={({ field: { onChange } }) => (
|
render={({ field: { onChange } }) => (
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full rounded-lg border bg-white backdrop-blur-sm dark:border-[#e2d7c5] dark:bg-[#fcf8ee]"
|
className="h-44 w-full rounded-lg border bg-white backdrop-blur-sm dark:border-[#e2d7c5] dark:bg-[#fcf8ee]"
|
||||||
|
defaultValue={user.signature ?? undefined}
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.signature} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
@@ -7,15 +11,25 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { FcGoogle } from 'react-icons/fc';
|
import { FcGoogle } from 'react-icons/fc';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
const ERROR_MESSAGES = {
|
||||||
|
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
|
||||||
|
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
|
||||||
|
[ErrorCode.USER_MISSING_PASSWORD]:
|
||||||
|
'This account appears to be using a social login method, please sign in using that method',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOGIN_REDIRECT_PATH = '/documents';
|
||||||
|
|
||||||
export const ZSignInFormSchema = z.object({
|
export const ZSignInFormSchema = z.object({
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
password: z.string().min(1),
|
password: z.string().min(6).max(72),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
||||||
@@ -25,6 +39,8 @@ export type SignInFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -39,17 +55,36 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
resolver: zodResolver(ZSignInFormSchema),
|
resolver: zodResolver(ZSignInFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const errorCode = searchParams?.get('error');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
if (isErrorCode(errorCode)) {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
description: ERROR_MESSAGES[errorCode] ?? 'An unknown error occurred',
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [errorCode, toast]);
|
||||||
|
|
||||||
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
|
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await signIn('credentials', {
|
await signIn('credentials', {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
callbackUrl: '/documents',
|
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// throw new Error('Not implemented');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'An unknown error occurred',
|
title: 'An unknown error occurred',
|
||||||
@@ -61,8 +96,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
|
|
||||||
const onSignInWithGoogleClick = async () => {
|
const onSignInWithGoogleClick = async () => {
|
||||||
try {
|
try {
|
||||||
await signIn('google', { callbackUrl: '/dashboard' });
|
await signIn('google', { callbackUrl: LOGIN_REDIRECT_PATH });
|
||||||
// throw new Error('Not implemented');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'An unknown error occurred',
|
title: 'An unknown error occurred',
|
||||||
@@ -76,10 +110,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||||
onSubmit={(e) => {
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
e.preventDefault();
|
|
||||||
handleSubmit(onFormSubmit)();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="email" className="text-slate-500">
|
<Label htmlFor="email" className="text-slate-500">
|
||||||
@@ -99,6 +130,9 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
minLength={6}
|
||||||
|
maxLength={72}
|
||||||
|
autoComplete="current-password"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
{...register('password')}
|
{...register('password')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
@@ -18,7 +19,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
export const ZSignUpFormSchema = z.object({
|
export const ZSignUpFormSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
password: z.string().min(1),
|
password: z.string().min(6).max(72),
|
||||||
|
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
||||||
@@ -31,6 +33,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
control,
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
@@ -39,15 +42,16 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
signature: '',
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZSignUpFormSchema),
|
resolver: zodResolver(ZSignUpFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, email, password }: TSignUpFormSchema) => {
|
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await signup({ name, email, password });
|
await signup({ name, email, password, signature });
|
||||||
|
|
||||||
await signIn('credentials', {
|
await signIn('credentials', {
|
||||||
email,
|
email,
|
||||||
@@ -105,6 +109,9 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
minLength={6}
|
||||||
|
maxLength={72}
|
||||||
|
autoComplete="new-password"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
{...register('password')}
|
{...register('password')}
|
||||||
/>
|
/>
|
||||||
@@ -116,8 +123,19 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<SignaturePad className="mt-2 h-36 w-full rounded-lg border bg-white dark:border-[#e2d7c5] dark:bg-[#fcf8ee]" />
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="signature"
|
||||||
|
render={({ field: { onChange } }) => (
|
||||||
|
<SignaturePad
|
||||||
|
className="mt-2 h-36 w-full rounded-lg border bg-white dark:border-[#e2d7c5] dark:bg-[#fcf8ee]"
|
||||||
|
onChange={(v) => onChange(v ?? '')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.signature} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button size="lg" disabled={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
<Button size="lg" disabled={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
|
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
|
||||||
|
|
||||||
import { TFeatureFlagValue, ZFeatureFlagValueSchema } from '~/providers/feature-flag';
|
import { TFeatureFlagValue, ZFeatureFlagValueSchema } from '~/providers/feature-flag.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluate whether a flag is enabled for the current user.
|
* Evaluate whether a flag is enabled for the current user.
|
||||||
@@ -32,7 +32,7 @@ export const getFlag = async (
|
|||||||
revalidate: 60,
|
revalidate: 60,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZFeatureFlagValueSchema.parse(res))
|
.then((res) => ZFeatureFlagValueSchema.parse(res))
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ export const getAllFlags = async (
|
|||||||
revalidate: 60,
|
revalidate: 60,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
|
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
|
||||||
.catch(() => LOCAL_FEATURE_FLAGS);
|
.catch(() => LOCAL_FEATURE_FLAGS);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ export default function PostHogServerClient() {
|
|||||||
|
|
||||||
return new PostHog(postHogConfig.key, {
|
return new PostHog(postHogConfig.key, {
|
||||||
host: postHogConfig.host,
|
host: postHogConfig.host,
|
||||||
fetch: (...args) => fetch(...args),
|
fetch: async (...args) => fetch(...args),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
18
apps/web/src/hooks/use-debounced-value.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useDebouncedValue<T>(value: T, delay: number) {
|
||||||
|
// State and setters for debounced value
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getToken } from 'next-auth/jwt';
|
||||||
|
|
||||||
export default async function middleware(req: NextRequest) {
|
export default async function middleware(req: NextRequest) {
|
||||||
if (req.nextUrl.pathname === '/') {
|
if (req.nextUrl.pathname === '/') {
|
||||||
const redirectUrl = new URL('/documents', req.url);
|
const redirectUrl = new URL('/documents', req.url);
|
||||||
@@ -7,19 +9,15 @@ export default async function middleware(req: NextRequest) {
|
|||||||
return NextResponse.redirect(redirectUrl);
|
return NextResponse.redirect(redirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (req.nextUrl.pathname.startsWith('/dashboard')) {
|
if (req.nextUrl.pathname.startsWith('/signin')) {
|
||||||
// const token = await getToken({ req });
|
const token = await getToken({ req });
|
||||||
|
|
||||||
// console.log('token', token);
|
if (token) {
|
||||||
|
const redirectUrl = new URL('/documents', req.url);
|
||||||
|
|
||||||
// if (!token) {
|
return NextResponse.redirect(redirectUrl);
|
||||||
// console.log('has no token', req.url);
|
}
|
||||||
// const redirectUrl = new URL('/signin', req.url);
|
}
|
||||||
// redirectUrl.searchParams.set('callbackUrl', req.url);
|
|
||||||
|
|
||||||
// return NextResponse.redirect(redirectUrl);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export default async function handler(
|
|||||||
|
|
||||||
if (user && user.Subscription.length > 0) {
|
if (user && user.Subscription.length > 0) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -104,7 +103,6 @@ export default async function handler(
|
|||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
metadata,
|
metadata,
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
||||||
email,
|
email,
|
||||||
|
|||||||