Compare commits
198 Commits
feat/docum
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbb332fb35 | ||
|
|
7e1cce9155 | ||
|
|
599e857a1e | ||
|
|
581f08c59b | ||
|
|
24a2e9e6d4 | ||
|
|
e8796a7d86 | ||
|
|
db3f75c42f | ||
|
|
00574325b9 | ||
|
|
99706e0ed6 | ||
|
|
326743d8a1 | ||
|
|
3f67b0f27e | ||
|
|
24036b0f24 | ||
|
|
fbf32404a6 | ||
|
|
975d52a07e | ||
|
|
f8a193c0f8 | ||
|
|
9186cb4d7b | ||
|
|
898f5a629c | ||
|
|
933076fa3f | ||
|
|
27edcebef6 | ||
|
|
abc91f7eac | ||
|
|
5862af3034 | ||
|
|
35acf05997 | ||
|
|
5969f148c8 | ||
|
|
660f5894a6 | ||
|
|
77058220a8 | ||
|
|
6cdba45396 | ||
|
|
67571158e8 | ||
|
|
171a5ba4ee | ||
|
|
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 | ||
|
|
af81d99b2a | ||
|
|
2751adc463 | ||
|
|
396ce9f3f3 | ||
|
|
3f4f66d878 | ||
|
|
d6751d7a26 | ||
|
|
0e32baff0b | ||
|
|
f76bf4c2c7 | ||
|
|
0d8532ab6d | ||
|
|
490d3d51e1 | ||
|
|
2ab796910e | ||
|
|
07102588be | ||
|
|
04f9422f24 | ||
|
|
05a7f5e178 | ||
|
|
7bae814f96 | ||
|
|
ea45e38fa0 | ||
|
|
6d9a85112f | ||
|
|
346efd19db | ||
|
|
617143a47f | ||
|
|
66f067276e | ||
|
|
fc10d0449f | ||
|
|
083f3e7108 | ||
|
|
af307a2a49 | ||
|
|
b063758ee5 | ||
|
|
4964b252e3 | ||
|
|
e468f5bbc9 | ||
|
|
c5b7b8a18a | ||
|
|
a8a1fbb829 | ||
|
|
3c2a4892e7 | ||
|
|
6d9e84d327 | ||
|
|
73b4e30c97 | ||
|
|
bd01545a70 | ||
|
|
6d360e581d | ||
|
|
ba95818da4 | ||
|
|
d0720f4c70 | ||
|
|
f60cb22f11 | ||
|
|
e0cb4314fb | ||
|
|
0571137a60 | ||
|
|
30aabf50eb | ||
|
|
8441a5eb98 | ||
|
|
259ab49bc1 | ||
|
|
2f2d5dfc0b | ||
|
|
0f27f4261b | ||
|
|
9b92cad2db | ||
|
|
ad1ff6159c | ||
|
|
f633b17f17 | ||
|
|
8fa16001e6 | ||
|
|
e111234460 | ||
|
|
034072f50e | ||
|
|
a7664d79fd | ||
|
|
45d0d3f7e8 | ||
|
|
6e62eb8d81 | ||
|
|
3b9c57fe5c | ||
|
|
90e28cd3a4 | ||
|
|
e743e56787 |
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
|
||||||
@@ -12,6 +12,8 @@ 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"
|
||||||
|
|
||||||
# [[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
|
||||||
@@ -52,7 +54,12 @@ NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
|||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
||||||
|
|
||||||
# [[FEATURES]]
|
# [[FEATURES]]
|
||||||
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false
|
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
||||||
|
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||||
|
# OPTIONAL: Defines the host to use for PostHog.
|
||||||
|
NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com"
|
||||||
|
# OPTIONAL: Leave blank to disable billing.
|
||||||
|
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||||
|
|
||||||
# This is only required for the marketing site
|
# This is only required for the marketing site
|
||||||
# [[REDIS]]
|
# [[REDIS]]
|
||||||
|
|||||||
7
.eslintignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Config files
|
||||||
|
*.config.js
|
||||||
|
*.config.cjs
|
||||||
|
|
||||||
|
# Statically hosted javascript files
|
||||||
|
apps/*/public/*.js
|
||||||
|
apps/*/public/*.cjs
|
||||||
32
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: 'github-actions'
|
||||||
|
directory: '/'
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
target-branch: "feat/refresh"
|
||||||
|
labels:
|
||||||
|
- "ci dependencies"
|
||||||
|
- "ci"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/apps/marketing"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
target-branch: "feat/refresh"
|
||||||
|
labels:
|
||||||
|
- "npm dependencies"
|
||||||
|
- "frontend"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/apps/web"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
target-branch: "feat/refresh"
|
||||||
|
labels:
|
||||||
|
- "npm dependencies"
|
||||||
|
- "frontend"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
4
.github/workflows/ci.yml
vendored
@@ -6,6 +6,10 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "feat/refresh" ]
|
branches: [ "feat/refresh" ]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
|
|
||||||
|
|||||||
45
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [ feat/refresh ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ feat/refresh ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: true
|
||||||
|
matrix:
|
||||||
|
language: [ 'javascript' ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build Documenso
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v2
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v2
|
||||||
21
.github/workflows/semantic-pull-requests.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: "Validate PR Name"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- edited
|
||||||
|
- synchronize
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-pr:
|
||||||
|
name: Validate PR title
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
10
.gitignore
vendored
@@ -37,3 +37,13 @@ yarn-error.log*
|
|||||||
|
|
||||||
# contentlayer
|
# contentlayer
|
||||||
.contentlayer
|
.contentlayer
|
||||||
|
|
||||||
|
# intellij
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# vscode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|||||||
4
.husky/commit-msg
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npm run commitlint -- $1
|
||||||
0
.husky/pre-commit
Normal file → Executable file
@@ -89,6 +89,10 @@ Documenso is built using awesome open source tech including:
|
|||||||
- [Node SignPDF (Digital Signature)](https://github.com/vbuch/node-signpdf)
|
- [Node SignPDF (Digital Signature)](https://github.com/vbuch/node-signpdf)
|
||||||
- [React-PDF for viewing PDFs](https://github.com/wojtekmaj/react-pdf)
|
- [React-PDF for viewing PDFs](https://github.com/wojtekmaj/react-pdf)
|
||||||
- [PDF-Lib for PDF manipulation](https://github.com/Hopding/pdf-lib)
|
- [PDF-Lib for PDF manipulation](https://github.com/Hopding/pdf-lib)
|
||||||
|
- [Zod for schema declaration and validation](https://zod.dev/)
|
||||||
|
- [Lucide React for icons in React app](https://lucide.dev/)
|
||||||
|
- [Framer Motion for motion library](https://www.framer.com/motion/)
|
||||||
|
- [Radix UI for component library](https://www.radix-ui.com/)
|
||||||
- Check out `/package.json` and `/apps/web/package.json` for more
|
- Check out `/package.json` and `/apps/web/package.json` for more
|
||||||
- Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned.
|
- Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned.
|
||||||
|
|
||||||
|
|||||||
56
apps/marketing/content/blog/next.mdx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
title: Preview the next Documenso
|
||||||
|
description: We're redesigning Documenso by making it more elegant and appropriately playful. Here's a sneak peek.
|
||||||
|
authorName: 'Timur Ercan'
|
||||||
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2023-08-21
|
||||||
|
tags:
|
||||||
|
- Design
|
||||||
|
- Preview
|
||||||
|
---
|
||||||
|
|
||||||
|
Since we launched [Documenso 0.9 on Product Hunt](https://producthunt.com/products/documenso#documenso) last May, the team's been hard at work behind the scenes to ramp up development and design to deliver an excellent next version.
|
||||||
|
|
||||||
|
Last week, Lucas shared the reasoning 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.
|
||||||
|
|
||||||
|
## Preview the next Documenso
|
||||||
|
|
||||||
|
We redesigned the whole signing flow to make it more appealing and more convenient.
|
||||||
|
|
||||||
|
We improved the overall look and feel by making it more elegant and appropriately playful. Focused on the task at hand, but explicitly enjoying doing it.
|
||||||
|
|
||||||
|
**We call it happy minimalism.**
|
||||||
|
|
||||||
|
We paid particular attention to the moment of signing, which should be celebrated.
|
||||||
|
|
||||||
|
The image below is the final bloom of the completion celebration we added:
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/blog-fig-preview-documenso.webp"
|
||||||
|
width="2000"
|
||||||
|
height="1268"
|
||||||
|
alt="Figure 1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">"You've signed a new document."</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
## Kicking off a new phase of collaboration
|
||||||
|
|
||||||
|
This preview also is the kickoff for a new phase of how we collaborate with the community.
|
||||||
|
|
||||||
|
We recently [switched to Discord](https://documenso.com/blog/switching-from-slack-to-discord) to set up a more developer-friendly, community-driven environment, and we just released the [public roadmap](https://documen.so/launches).
|
||||||
|
|
||||||
|
As always, if you have any questions or feedback, please reach out. We love to hear from you.
|
||||||
|
|
||||||
|
Best from Hamburg,
|
||||||
|
|
||||||
|
Timur
|
||||||
|
|
||||||
|
Make sure to [star the GitHub repository](https://documen.so/github), [follow us on X](http://documen.so/twitter) and [join the Discord server](https://documen.so/discord) to keep up to date with all things Documenso.
|
||||||
|
|
||||||
|
We're building a beautiful, open-source alternative to DocuSign.
|
||||||
@@ -4,10 +4,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=3001 next dev",
|
"dev": "next dev -p 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
@@ -29,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"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
apps/marketing/public/blog/blog-fig-preview-documenso.webp
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
apps/marketing/public/logo_icon.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
56611
apps/marketing/public/pdf.worker.min.js
vendored
Normal file
@@ -5,7 +5,7 @@ import { allDocuments } from 'contentlayer/generated';
|
|||||||
import type { MDXComponents } from 'mdx/types';
|
import 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"
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ export const FUNDING_RAISED = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2023-05',
|
date: '2023-05',
|
||||||
amount: 300_000,
|
amount: 290_000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2023-07',
|
date: '2023-07',
|
||||||
amount: 1_550_000,
|
amount: 1_540_000,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { FundingRaised } from './funding-raised';
|
|||||||
import { GithubMetric } from './gh-metrics';
|
import { 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);
|
||||||
|
|
||||||
|
|||||||
14
apps/marketing/src/app/robots.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/*',
|
||||||
|
disallow: ['/_next/*'],
|
||||||
|
},
|
||||||
|
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
41
apps/marketing/src/app/sitemap.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
import { allBlogPosts, allGenericPages } from 'contentlayer/generated';
|
||||||
|
|
||||||
|
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
const lastModified = new Date();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: baseUrl,
|
||||||
|
lastModified,
|
||||||
|
},
|
||||||
|
...allGenericPages.map((doc) => ({
|
||||||
|
url: `${baseUrl}/${doc._raw.flattenedPath}`,
|
||||||
|
lastModified,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/blog`,
|
||||||
|
lastModified,
|
||||||
|
},
|
||||||
|
...allBlogPosts.map((doc) => ({
|
||||||
|
url: `${baseUrl}/${doc._raw.flattenedPath}`,
|
||||||
|
lastModified,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/open`,
|
||||||
|
lastModified,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/oss-friends`,
|
||||||
|
lastModified,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/pricing`,
|
||||||
|
lastModified,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
BIN
apps/marketing/src/assets/background-blog-og.png
Normal file
|
After Width: | Height: | Size: 896 KiB |
BIN
apps/marketing/src/assets/inter-bold.ttf
Normal file
BIN
apps/marketing/src/assets/inter-regular.ttf
Normal file
@@ -5,7 +5,7 @@ import React, { useState } from 'react';
|
|||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Info, Loader } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
import { usePlausible } from 'next-plausible';
|
import { usePlausible } from 'next-plausible';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -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 }),
|
||||||
@@ -85,7 +87,7 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -97,50 +99,49 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
className={cn('flex flex-col gap-y-4', className)}
|
<fieldset disabled={isSubmitting} 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">
|
||||||
{params?.get('cancelled') === 'true' && (
|
<div className="flex">
|
||||||
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
|
<div className="flex-shrink-0">
|
||||||
<div className="flex">
|
<Info className="h-5 w-5 text-yellow-400" />
|
||||||
<div className="flex-shrink-0">
|
</div>
|
||||||
<Info className="h-5 w-5 text-yellow-400" />
|
<div className="ml-3">
|
||||||
</div>
|
<p className="text-sm leading-5 text-yellow-700">
|
||||||
<div className="ml-3">
|
You have cancelled the payment process. If you didn't mean to do this, please
|
||||||
<p className="text-sm leading-5 text-yellow-700">
|
try again.
|
||||||
You have cancelled the payment process. If you didn't mean to do this, please
|
</p>
|
||||||
try again.
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</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>
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-slate-500">Name</Label>
|
<Label className="text-slate-500">Email</Label>
|
||||||
|
|
||||||
<Input type="text" className="mt-2" {...register('name')} autoFocus />
|
<Input type="email" className="mt-2" {...register('email')} />
|
||||||
|
|
||||||
<FormErrorMessage className="mt-1" error={errors.name} />
|
<FormErrorMessage className="mt-1" error={errors.email} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<Button type="submit" size="lg" loading={isSubmitting}>
|
||||||
<Label className="text-slate-500">Email</Label>
|
Claim the Community Plan (
|
||||||
|
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
|
||||||
<Input type="email" className="mt-2" {...register('email')} />
|
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
||||||
|
? 'Monthly'
|
||||||
<FormErrorMessage className="mt-1" error={errors.email} />
|
: 'Yearly'}
|
||||||
</div>
|
)
|
||||||
|
</Button>
|
||||||
<Button type="submit" size="lg" disabled={isSubmitting}>
|
</fieldset>
|
||||||
{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>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -3,12 +3,28 @@ import { HTMLAttributes } from 'react';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Github, Slack, Twitter } from 'lucide-react';
|
import { Github, MessagesSquare, Twitter } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
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,69 +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]"
|
</Link>
|
||||||
>
|
))}
|
||||||
<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>
|
</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">
|
||||||
<Link
|
{FOOTER_LINKS.map((link, index) => (
|
||||||
href="/pricing"
|
<Link
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
key={index}
|
||||||
>
|
href={link.href}
|
||||||
Pricing
|
target={link.target}
|
||||||
</Link>
|
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||||
|
>
|
||||||
<Link href="/blog" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
|
{link.text}
|
||||||
Blog
|
</Link>
|
||||||
</Link>
|
))}
|
||||||
|
|
||||||
<Link href="/open" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
|
|
||||||
Open
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://status.documenso.com"
|
|
||||||
target="_blank"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Status
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="mailto:support@documenso.com"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Support
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/privacy"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Privacy
|
|
||||||
</Link>
|
|
||||||
</div>
|
</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">
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
'use client';
|
||||||
|
|
||||||
|
import { HTMLAttributes, useState } from 'react';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
import { HamburgerMenu } from './mobile-hamburger';
|
||||||
|
import { MobileNavigation } from './mobile-navigation';
|
||||||
|
|
||||||
export type HeaderProps = HTMLAttributes<HTMLElement>;
|
export type HeaderProps = HTMLAttributes<HTMLElement>;
|
||||||
|
|
||||||
export const Header = ({ className, ...props }: HeaderProps) => {
|
export const Header = ({ className, ...props }: HeaderProps) => {
|
||||||
|
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={cn('flex items-center justify-between', className)} {...props}>
|
<header className={cn('flex items-center justify-between', className)} {...props}>
|
||||||
<Link href="/">
|
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
||||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={0}></Image>
|
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center gap-x-6">
|
<div className="hidden items-center gap-x-6 md:flex">
|
||||||
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
||||||
Pricing
|
Pricing
|
||||||
</Link>
|
</Link>
|
||||||
@@ -35,6 +42,15 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
|||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<HamburgerMenu
|
||||||
|
onToggleMenuOpen={() => setIsHamburgerMenuOpen((v) => !v)}
|
||||||
|
isMenuOpen={isHamburgerMenuOpen}
|
||||||
|
/>
|
||||||
|
<MobileNavigation
|
||||||
|
isMenuOpen={isHamburgerMenuOpen}
|
||||||
|
onMenuOpenChange={setIsHamburgerMenuOpen}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Menu, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export interface HamburgerMenuProps {
|
||||||
|
isMenuOpen: boolean;
|
||||||
|
onToggleMenuOpen?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HamburgerMenu = ({ isMenuOpen, onToggleMenuOpen }: HamburgerMenuProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex md:hidden">
|
||||||
|
<Button variant="outline" className="z-20 w-10 p-0" onClick={onToggleMenuOpen}>
|
||||||
|
{isMenuOpen ? <X /> : <Menu />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
126
apps/marketing/src/components/(marketing)/mobile-navigation.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { motion, useReducedMotion } from 'framer-motion';
|
||||||
|
import { Github, MessagesSquare, Twitter } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||||
|
|
||||||
|
export type MobileNavigationProps = {
|
||||||
|
isMenuOpen: boolean;
|
||||||
|
onMenuOpenChange?: (_value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MENU_NAVIGATION_LINKS = [
|
||||||
|
{
|
||||||
|
href: '/blog',
|
||||||
|
text: 'Blog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/pricing',
|
||||||
|
text: 'Pricing',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/open',
|
||||||
|
text: 'Open',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://status.documenso.com',
|
||||||
|
text: 'Status',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'mailto:support@documenso.com',
|
||||||
|
text: 'Support',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/privacy',
|
||||||
|
text: 'Privacy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://app.documenso.com/login',
|
||||||
|
text: 'Sign in',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
const handleMenuItemClick = () => {
|
||||||
|
onMenuOpenChange?.(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||||
|
<SheetContent className="w-full max-w-[400px]">
|
||||||
|
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
|
||||||
|
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="mt-12 flex w-full flex-col items-start gap-y-4"
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
transition={{
|
||||||
|
staggerChildren: 0.03,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
|
||||||
|
<motion.div
|
||||||
|
key={href}
|
||||||
|
variants={{
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
x: shouldReduceMotion ? 0 : 100,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
ease: 'backInOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className="text-2xl font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||||
|
href={href}
|
||||||
|
onClick={() => handleMenuItemClick()}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="mx-auto mt-8 flex w-full flex-wrap items-center gap-x-4 gap-y-4 ">
|
||||||
|
<Link
|
||||||
|
href="https://twitter.com/documenso"
|
||||||
|
target="_blank"
|
||||||
|
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||||
|
>
|
||||||
|
<Twitter className="h-6 w-6" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="https://github.com/documenso/documenso"
|
||||||
|
target="_blank"
|
||||||
|
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||||
|
>
|
||||||
|
<Github className="h-6 w-6" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="https://documen.so/discord"
|
||||||
|
target="_blank"
|
||||||
|
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||||
|
>
|
||||||
|
<MessagesSquare className="h-6 w-6" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ export const PasswordReveal = ({ password }: PasswordRevealProps) => {
|
|||||||
const [, copy] = useCopyToClipboard();
|
const [, 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]);
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
import { claimPlan } from '~/api/claim-plan/fetcher';
|
||||||
|
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
import { FormErrorMessage } from '../form/form-error-message';
|
||||||
import { SignaturePad } from '../signature-pad';
|
|
||||||
|
|
||||||
const ZWidgetFormSchema = z
|
const ZWidgetFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -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]);
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
HTMLAttributes,
|
|
||||||
MouseEvent,
|
|
||||||
PointerEvent,
|
|
||||||
TouchEvent,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import { StrokeOptions, getStroke } from 'perfect-freehand';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
import { getSvgPathFromStroke } from './helper';
|
|
||||||
import { Point } from './point';
|
|
||||||
|
|
||||||
const DPI = 2;
|
|
||||||
|
|
||||||
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
|
|
||||||
onChange?: (_signatureDataUrl: string | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProps) => {
|
|
||||||
const $el = useRef<HTMLCanvasElement>(null);
|
|
||||||
|
|
||||||
const [isPressed, setIsPressed] = useState(false);
|
|
||||||
const [points, setPoints] = useState<Point[]>([]);
|
|
||||||
|
|
||||||
const perfectFreehandOptions = useMemo(() => {
|
|
||||||
const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10;
|
|
||||||
|
|
||||||
return {
|
|
||||||
size,
|
|
||||||
thinning: 0.25,
|
|
||||||
streamline: 0.5,
|
|
||||||
smoothing: 0.5,
|
|
||||||
end: {
|
|
||||||
taper: size * 2,
|
|
||||||
},
|
|
||||||
} satisfies StrokeOptions;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onMouseDown = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
|
||||||
if (event.cancelable) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsPressed(true);
|
|
||||||
|
|
||||||
const point = Point.fromEvent(event, DPI, $el.current);
|
|
||||||
|
|
||||||
const newPoints = [...points, point];
|
|
||||||
|
|
||||||
setPoints(newPoints);
|
|
||||||
|
|
||||||
if ($el.current) {
|
|
||||||
const ctx = $el.current.getContext('2d');
|
|
||||||
|
|
||||||
if (ctx) {
|
|
||||||
ctx.save();
|
|
||||||
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
|
||||||
ctx.imageSmoothingQuality = 'high';
|
|
||||||
|
|
||||||
const pathData = new Path2D(
|
|
||||||
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.fill(pathData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
|
||||||
if (event.cancelable) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPressed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const point = Point.fromEvent(event, DPI, $el.current);
|
|
||||||
|
|
||||||
if (point.distanceTo(points[points.length - 1]) > 5) {
|
|
||||||
const newPoints = [...points, point];
|
|
||||||
|
|
||||||
setPoints(newPoints);
|
|
||||||
|
|
||||||
if ($el.current) {
|
|
||||||
const ctx = $el.current.getContext('2d');
|
|
||||||
|
|
||||||
if (ctx) {
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
|
||||||
ctx.imageSmoothingQuality = 'high';
|
|
||||||
|
|
||||||
const pathData = new Path2D(
|
|
||||||
getSvgPathFromStroke(getStroke(points, perfectFreehandOptions)),
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.fill(pathData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addPoint = true) => {
|
|
||||||
if (event.cancelable) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsPressed(false);
|
|
||||||
|
|
||||||
const point = Point.fromEvent(event, DPI, $el.current);
|
|
||||||
|
|
||||||
const newPoints = [...points];
|
|
||||||
|
|
||||||
if (addPoint) {
|
|
||||||
newPoints.push(point);
|
|
||||||
|
|
||||||
setPoints(newPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($el.current) {
|
|
||||||
const ctx = $el.current.getContext('2d');
|
|
||||||
|
|
||||||
if (ctx) {
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
|
||||||
ctx.imageSmoothingQuality = 'high';
|
|
||||||
|
|
||||||
const pathData = new Path2D(
|
|
||||||
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.fill(pathData);
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange?.($el.current.toDataURL());
|
|
||||||
}
|
|
||||||
|
|
||||||
setPoints([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
|
||||||
if (event.cancelable) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('buttons' in event && event.buttons === 1) {
|
|
||||||
onMouseDown(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseLeave = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
|
||||||
if (event.cancelable) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMouseUp(event, false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClearClick = () => {
|
|
||||||
if ($el.current) {
|
|
||||||
const ctx = $el.current.getContext('2d');
|
|
||||||
|
|
||||||
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange?.(null);
|
|
||||||
|
|
||||||
setPoints([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if ($el.current) {
|
|
||||||
$el.current.width = $el.current.clientWidth * DPI;
|
|
||||||
$el.current.height = $el.current.clientHeight * DPI;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative block">
|
|
||||||
<canvas
|
|
||||||
ref={$el}
|
|
||||||
className={cn('relative block', className)}
|
|
||||||
style={{ touchAction: 'none' }}
|
|
||||||
onPointerMove={(event) => onMouseMove(event)}
|
|
||||||
onPointerDown={(event) => onMouseDown(event)}
|
|
||||||
onPointerUp={(event) => onMouseUp(event)}
|
|
||||||
onPointerLeave={(event) => onMouseLeave(event)}
|
|
||||||
onPointerEnter={(event) => onMouseEnter(event)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute bottom-2 right-2">
|
|
||||||
<button className="rounded-full p-2 text-xs text-slate-500" onClick={() => onClearClick()}>
|
|
||||||
Clear Signature
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
27
apps/marketing/src/hooks/use-window-size.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useWindowSize() {
|
||||||
|
const [size, setSize] = useState({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
setSize({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onResize();
|
||||||
|
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
@@ -43,7 +43,6 @@ export default async function handler(
|
|||||||
|
|
||||||
if (user && user.Subscription.length > 0) {
|
if (user && user.Subscription.length > 0) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -104,7 +103,6 @@ export default async function handler(
|
|||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
metadata,
|
metadata,
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -17,14 +17,13 @@ import {
|
|||||||
SigningStatus,
|
SigningStatus,
|
||||||
} from '@documenso/prisma/client';
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
const log = (...args: any[]) => console.log('[stripe]', ...args);
|
const log = (...args: unknown[]) => console.log('[stripe]', ...args);
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: { bodyParser: false },
|
api: { bodyParser: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
|
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
|
||||||
// return res.status(500).json({
|
// return res.status(500).json({
|
||||||
// success: false,
|
// success: false,
|
||||||
@@ -55,6 +54,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
log('event-type:', event.type);
|
log('event-type:', event.type);
|
||||||
|
|
||||||
if (event.type === 'checkout.session.completed') {
|
if (event.type === 'checkout.session.completed') {
|
||||||
|
// This typecast is required since we don't want to create a guard for every event type
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const session = event.data.object as Stripe.Checkout.Session;
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
|
||||||
if (session.metadata?.source === 'landing') {
|
if (session.metadata?.source === 'landing') {
|
||||||
|
|||||||
@@ -9,16 +9,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": [
|
"~/*": ["./src/*"],
|
||||||
"./src/*"
|
"contentlayer/generated": ["./.contentlayer/generated"]
|
||||||
],
|
|
||||||
"contentlayer/generated": [
|
|
||||||
"./.contentlayer/generated"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"types": [
|
"types": ["@documenso/lib/types/next-auth.d.ts"],
|
||||||
"@documenso/lib/types/next-auth.d.ts"
|
|
||||||
],
|
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"incremental": false
|
"incremental": false
|
||||||
},
|
},
|
||||||
@@ -29,7 +23,5 @@
|
|||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".contentlayer/generated"
|
".contentlayer/generated"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": ["node_modules"]
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
@@ -18,7 +19,10 @@ const config = {
|
|||||||
'@documenso/ui',
|
'@documenso/ui',
|
||||||
'@documenso/email',
|
'@documenso/email',
|
||||||
],
|
],
|
||||||
env,
|
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 }}',
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=3000 next dev",
|
"dev": "next dev -p 3000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"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": "*",
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"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",
|
"nanoid": "^4.0.2",
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
@@ -27,19 +30,22 @@
|
|||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
|
"posthog-js": "^1.75.3",
|
||||||
|
"posthog-node": "^3.1.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.8.0",
|
||||||
"react-pdf": "^7.1.1",
|
|
||||||
"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"
|
||||||
|
|||||||
2
apps/web/process-env.d.ts
vendored
@@ -10,8 +10,6 @@ declare namespace NodeJS {
|
|||||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||||
|
|
||||||
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: string;
|
|
||||||
|
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
|
||||||
}
|
}
|
||||||
|
|||||||
56611
apps/web/public/pdf.worker.min.js
vendored
Normal file
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,28 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
||||||
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 { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
|
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
||||||
|
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||||
|
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
||||||
|
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||||
|
import {
|
||||||
|
DocumentFlowFormContainer,
|
||||||
|
DocumentFlowFormContainerHeader,
|
||||||
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
|
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { LazyPDFViewer } from '~/components/(dashboard)/pdf-viewer/lazy-pdf-viewer';
|
import { addFields } from '~/components/forms/edit-document/add-fields.action';
|
||||||
import { AddFieldsFormPartial } from '~/components/forms/edit-document/add-fields';
|
import { addSigners } from '~/components/forms/edit-document/add-signers.action';
|
||||||
import { AddSignersFormPartial } from '~/components/forms/edit-document/add-signers';
|
import { completeDocument } from '~/components/forms/edit-document/add-subject.action';
|
||||||
import { AddSubjectFormPartial } from '~/components/forms/edit-document/add-subject';
|
|
||||||
|
|
||||||
export type EditDocumentFormProps = {
|
export type EditDocumentFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -19,6 +33,8 @@ export type EditDocumentFormProps = {
|
|||||||
fields: Field[];
|
fields: Field[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
||||||
|
|
||||||
export const EditDocumentForm = ({
|
export const EditDocumentForm = ({
|
||||||
className,
|
className,
|
||||||
document,
|
document,
|
||||||
@@ -26,27 +42,109 @@ export const EditDocumentForm = ({
|
|||||||
fields,
|
fields,
|
||||||
user: _user,
|
user: _user,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers');
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<EditDocumentStep>('signers');
|
||||||
|
|
||||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||||
|
|
||||||
const onNextStep = () => {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
if (step === 'signers') {
|
signers: {
|
||||||
setStep('fields');
|
title: 'Add Signers',
|
||||||
}
|
description: 'Add the people who will sign the document.',
|
||||||
|
stepIndex: 1,
|
||||||
|
onSubmit: () => onAddSignersFormSubmit,
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
title: 'Add Fields',
|
||||||
|
description: 'Add all relevant fields for each recipient.',
|
||||||
|
stepIndex: 2,
|
||||||
|
onBackStep: () => setStep('signers'),
|
||||||
|
onSubmit: () => onAddFieldsFormSubmit,
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
title: 'Add Subject',
|
||||||
|
description: 'Add the subject and message you wish to send to signers.',
|
||||||
|
stepIndex: 3,
|
||||||
|
onBackStep: () => setStep('fields'),
|
||||||
|
onSubmit: () => onAddSubjectFormSubmit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
if (step === 'fields') {
|
const currentDocumentFlow = documentFlow[step];
|
||||||
setStep('subject');
|
|
||||||
|
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||||
|
try {
|
||||||
|
// Custom invocation server action
|
||||||
|
await addSigners({
|
||||||
|
documentId: document.id,
|
||||||
|
signers: data.signers,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
setStep('fields');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while adding signers.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPreviousStep = () => {
|
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
||||||
if (step === 'fields') {
|
try {
|
||||||
setStep('signers');
|
// Custom invocation server action
|
||||||
}
|
await addFields({
|
||||||
|
documentId: document.id,
|
||||||
|
fields: data.fields,
|
||||||
|
});
|
||||||
|
|
||||||
if (step === 'subject') {
|
router.refresh();
|
||||||
setStep('fields');
|
|
||||||
|
setStep('subject');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while adding signers.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||||
|
const { subject, message } = data.email;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await completeDocument({
|
||||||
|
documentId: document.id,
|
||||||
|
email: {
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Document sent',
|
||||||
|
description: 'Your document has been sent successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push('/documents');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while sending the document.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,35 +160,43 @@ export const EditDocumentForm = ({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
{step === 'signers' && (
|
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
|
||||||
<AddSignersFormPartial
|
<DocumentFlowFormContainerHeader
|
||||||
recipients={recipients}
|
title={currentDocumentFlow.title}
|
||||||
fields={fields}
|
description={currentDocumentFlow.description}
|
||||||
document={document}
|
|
||||||
onContinue={onNextStep}
|
|
||||||
onGoBack={onPreviousStep}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'fields' && (
|
{step === 'signers' && (
|
||||||
<AddFieldsFormPartial
|
<AddSignersFormPartial
|
||||||
recipients={recipients}
|
documentFlow={documentFlow.signers}
|
||||||
fields={fields}
|
recipients={recipients}
|
||||||
document={document}
|
fields={fields}
|
||||||
onContinue={onNextStep}
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
onGoBack={onPreviousStep}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'subject' && (
|
{step === 'fields' && (
|
||||||
<AddSubjectFormPartial
|
<AddFieldsFormPartial
|
||||||
recipients={recipients}
|
documentFlow={documentFlow.fields}
|
||||||
fields={fields}
|
recipients={recipients}
|
||||||
document={document}
|
fields={fields}
|
||||||
onContinue={onNextStep}
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
onGoBack={onPreviousStep}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{step === 'subject' && (
|
||||||
|
<AddSubjectFormPartial
|
||||||
|
documentFlow={documentFlow.subject}
|
||||||
|
document={document}
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
|
onSubmit={onAddSubjectFormSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DocumentFlowFormContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,34 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
import { PDFViewerProps } from '~/components/(dashboard)/pdf-viewer/pdf-viewer';
|
import { PDFViewerProps } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
export type LoadablePDFCard = PDFViewerProps & {
|
export type LoadablePDFCard = PDFViewerProps & {
|
||||||
className?: string;
|
className?: string;
|
||||||
pdfClassName?: string;
|
pdfClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
|
||||||
<Loader className="h-12 w-12 animate-spin text-slate-500" />
|
|
||||||
|
|
||||||
<p className="mt-4 text-slate-500">Loading document...</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
|
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
|
||||||
return (
|
return (
|
||||||
<Card className={className} gradient {...props}>
|
<Card className={className} gradient {...props}>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer className={pdfClassName} {...props} />
|
<LazyPDFViewer className={pdfClassName} {...props} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ export default function Loading() {
|
|||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Documents
|
Documents
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
Loading Document...
|
Loading Document...
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mt-8 grid min-h-[80vh] w-full grid-cols-12 gap-x-8">
|
<div className="mt-8 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
||||||
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
|
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||||
<div className="flex min-h-[80vh] flex-col items-center justify-center">
|
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
||||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
|||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { 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 { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||||
|
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
export type DocumentPageProps = {
|
||||||
@@ -69,18 +72,28 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
<div className="text-muted-foreground flex items-center">
|
<div className="text-muted-foreground flex items-center">
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||||
|
<span>{recipients.length} Recipient(s)</span>
|
||||||
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditDocumentForm
|
{document.status !== InternalDocumentStatus.COMPLETED && (
|
||||||
className="mt-8"
|
<EditDocumentForm
|
||||||
document={document}
|
className="mt-8"
|
||||||
user={session}
|
document={document}
|
||||||
recipients={recipients}
|
user={session}
|
||||||
fields={fields}
|
recipients={recipients}
|
||||||
/>
|
fields={fields}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||||
|
<div className="mx-auto mt-12 max-w-2xl">
|
||||||
|
<LazyPDFViewer document={`data:application/pdf;base64,${document.document}`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Edit, Pencil, Share } from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export type DataTableActionButtonProps = {
|
||||||
|
row: Document & {
|
||||||
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
Recipient: Recipient[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||||
|
|
||||||
|
const isOwner = row.User.id === session.user.id;
|
||||||
|
const isRecipient = !!recipient;
|
||||||
|
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||||
|
const isPending = row.status === DocumentStatus.PENDING;
|
||||||
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
|
||||||
|
return match({
|
||||||
|
isOwner,
|
||||||
|
isRecipient,
|
||||||
|
isDraft,
|
||||||
|
isPending,
|
||||||
|
isComplete,
|
||||||
|
isSigned,
|
||||||
|
})
|
||||||
|
.with({ isOwner: true, isDraft: true }, () => (
|
||||||
|
<Button className="w-24" asChild>
|
||||||
|
<Link href={`/documents/${row.id}`}>
|
||||||
|
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
|
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||||
|
<Button className="w-24" asChild>
|
||||||
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
|
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
Sign
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<Button className="w-24" disabled>
|
||||||
|
<Share className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
));
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
Edit,
|
||||||
|
History,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Share,
|
||||||
|
Trash2,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
|
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
export type DataTableActionDropdownProps = {
|
||||||
|
row: Document & {
|
||||||
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
Recipient: Recipient[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||||
|
|
||||||
|
const isOwner = row.User.id === session.user.id;
|
||||||
|
// const isRecipient = !!recipient;
|
||||||
|
// const isDraft = row.status === DocumentStatus.DRAFT;
|
||||||
|
// const isPending = row.status === DocumentStatus.PENDING;
|
||||||
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
|
||||||
|
const onDownloadClick = () => {
|
||||||
|
let decodedDocument = row.document;
|
||||||
|
|
||||||
|
try {
|
||||||
|
decodedDocument = atob(decodedDocument);
|
||||||
|
} catch (err) {
|
||||||
|
// We're just going to ignore this error and try to download the document
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
|
||||||
|
|
||||||
|
const blob = new Blob([documentBytes], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = window.document.createElement('a');
|
||||||
|
|
||||||
|
link.href = window.URL.createObjectURL(blob);
|
||||||
|
link.download = row.title || 'document.pdf';
|
||||||
|
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
window.URL.revokeObjectURL(link.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<MoreHorizontal className="h-5 w-5 text-gray-500" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled={!recipient || 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: 'created',
|
||||||
|
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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 && (
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Plus } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||||
import { 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 { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
import { DocumentDropzone } from '~/components/(dashboard)/document-dropzone/document-dropzone';
|
|
||||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||||
import {
|
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||||
PeriodSelectorValue,
|
|
||||||
isPeriodSelectorValue,
|
|
||||||
} from '~/components/(dashboard)/period-selector/types';
|
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
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;
|
||||||
@@ -30,20 +24,20 @@ 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 results = await findDocuments({
|
const results = await findDocuments({
|
||||||
userId: session.id,
|
userId: user.id,
|
||||||
status: status === 'ALL' ? undefined : status,
|
status,
|
||||||
orderBy: {
|
orderBy: {
|
||||||
column: 'created',
|
column: 'created',
|
||||||
direction: 'desc',
|
direction: 'desc',
|
||||||
@@ -52,8 +46,6 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
perPage,
|
perPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isNoResults = status === 'ALL' && period === '' && results.data.length === 0;
|
|
||||||
|
|
||||||
const getTabHref = (value: typeof status) => {
|
const getTabHref = (value: typeof status) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
@@ -63,73 +55,48 @@ 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()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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="text-4xl font-semibold">All Documents</h1>
|
<UploadDocument />
|
||||||
|
|
||||||
|
<h1 className="mt-12 text-4xl font-semibold">Documents</h1>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
|
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
|
||||||
<Tabs defaultValue={status}>
|
<Tabs defaultValue={status} className="overflow-x-auto">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger className="min-w-[60px]" value="ALL" asChild>
|
{[
|
||||||
<Link href={getTabHref('ALL')}>All</Link>
|
ExtendedDocumentStatus.INBOX,
|
||||||
</TabsTrigger>
|
ExtendedDocumentStatus.PENDING,
|
||||||
|
ExtendedDocumentStatus.COMPLETED,
|
||||||
|
ExtendedDocumentStatus.DRAFT,
|
||||||
|
ExtendedDocumentStatus.ALL,
|
||||||
|
].map((value) => (
|
||||||
|
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
|
||||||
|
<Link href={getTabHref(value)} scroll={false}>
|
||||||
|
<DocumentStatus status={value} />
|
||||||
|
|
||||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.DRAFT} asChild>
|
{value !== ExtendedDocumentStatus.ALL && (
|
||||||
<Link href={getTabHref(InternalDocumentStatus.DRAFT)}>
|
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||||
<DocumentStatus status={InternalDocumentStatus.DRAFT} />
|
{Math.min(stats[value], 99)}
|
||||||
|
{stats[value] > 99 && '+'}
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
</span>
|
||||||
{Math.min(stats.DRAFT, 99)}
|
)}
|
||||||
</span>
|
</Link>
|
||||||
</Link>
|
</TabsTrigger>
|
||||||
</TabsTrigger>
|
))}
|
||||||
|
|
||||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.PENDING} asChild>
|
|
||||||
<Link href={getTabHref(InternalDocumentStatus.PENDING)}>
|
|
||||||
<DocumentStatus status={InternalDocumentStatus.PENDING} />
|
|
||||||
|
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
|
||||||
{Math.min(stats.PENDING, 99)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
|
|
||||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.COMPLETED} asChild>
|
|
||||||
<Link href={getTabHref(InternalDocumentStatus.COMPLETED)}>
|
|
||||||
<DocumentStatus status={InternalDocumentStatus.COMPLETED} />
|
|
||||||
|
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
|
||||||
{Math.min(stats.COMPLETED, 99)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
<div className="flex flex-1 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||||
<PeriodSelector />
|
<PeriodSelector />
|
||||||
|
|
||||||
<Button>
|
|
||||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
|
||||||
Add Document
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
{/* If we're viewing all documents for all time and there's nuffin we should should an add document component instead */}
|
<DocumentsDataTable results={results} />
|
||||||
{isNoResults ? (
|
|
||||||
<DocumentDropzone className="min-h-[60vh] md:min-h-[40vh]" />
|
|
||||||
) : (
|
|
||||||
<DocumentsDataTable results={results} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
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';
|
import { useCreateDocument } from '~/api/document/create/fetcher';
|
||||||
import { DocumentDropzone } from '~/components/(dashboard)/document-dropzone/document-dropzone';
|
|
||||||
|
|
||||||
export type UploadDocumentProps = {
|
export type UploadDocumentProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -1,29 +1,75 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||||
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||||
|
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { PasswordForm } from '~/components/forms/password';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
|
||||||
|
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
const user = await getRequiredServerComponentSession();
|
const user = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
||||||
|
|
||||||
// Redirect if subscriptions are not enabled.
|
// Redirect if subscriptions are not enabled.
|
||||||
if (!IS_SUBSCRIPTIONS_ENABLED) {
|
if (!isBillingEnabled) {
|
||||||
redirect('/settings/profile');
|
redirect('/settings/profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let subscription = await getSubscriptionByUserId({ userId: user.id });
|
||||||
|
|
||||||
|
// If we don't have a customer record, create one as well as an empty subscription.
|
||||||
|
if (!subscription?.customerId) {
|
||||||
|
subscription = await createCustomer({ user });
|
||||||
|
}
|
||||||
|
|
||||||
|
let billingPortalUrl = '';
|
||||||
|
|
||||||
|
if (subscription?.customerId) {
|
||||||
|
billingPortalUrl = await getPortalSession({
|
||||||
|
customerId: subscription.customerId,
|
||||||
|
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,8 +12,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
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 '~/components/signature-pad';
|
|
||||||
|
|
||||||
import { useRequiredSigningContext } from './provider';
|
import { useRequiredSigningContext } from './provider';
|
||||||
|
|
||||||
@@ -51,7 +50,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className={cn(
|
className={cn(
|
||||||
'dark:bg-background border-border bg-widget sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border px-4 py-6',
|
'dark:bg-background border-border bg-widget sticky top-20 flex h-full max-h-[80rem] flex-col rounded-xl border px-4 py-6',
|
||||||
)}
|
)}
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
@@ -65,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>
|
||||||
@@ -88,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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -99,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"
|
||||||
>
|
>
|
||||||
@@ -110,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
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ 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 { 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';
|
||||||
@@ -9,11 +11,10 @@ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-re
|
|||||||
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 '~/components/(dashboard)/pdf-viewer/lazy-pdf-viewer';
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
|
|
||||||
|
|
||||||
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';
|
||||||
@@ -43,10 +44,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const user = await getServerComponentSession();
|
||||||
|
|
||||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningProvider email={recipient.email} fullName={recipient.name}>
|
<SigningProvider email={recipient.email} fullName={recipient.name} signature={user?.signature}>
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
{document.title}
|
{document.title}
|
||||||
@@ -58,7 +61,7 @@ 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
|
||||||
@@ -85,6 +88,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
.with(FieldType.DATE, () => (
|
.with(FieldType.DATE, () => (
|
||||||
<DateField key={field.id} field={field} recipient={recipient} />
|
<DateField key={field.id} field={field} recipient={recipient} />
|
||||||
))
|
))
|
||||||
|
.with(FieldType.EMAIL, () => (
|
||||||
|
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
.otherwise(() => null),
|
.otherwise(() => null),
|
||||||
)}
|
)}
|
||||||
</ElementVisible>
|
</ElementVisible>
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export const useRequiredSigningContext = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface SigningProviderProps {
|
export interface SigningProviderProps {
|
||||||
fullName?: string;
|
fullName?: string | null;
|
||||||
email?: string;
|
email?: string | null;
|
||||||
signature?: string;
|
signature?: string | null;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { SignaturePad } from '~/components/signature-pad';
|
|
||||||
|
|
||||||
import { useRequiredSigningContext } from './provider';
|
import { useRequiredSigningContext } from './provider';
|
||||||
import { SigningFieldContainer } from './signing-field-container';
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
@@ -64,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;
|
||||||
@@ -142,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>
|
||||||
)}
|
)}
|
||||||
@@ -183,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
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
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';
|
||||||
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
|
import { getServerComponentAllFlags } from '~/helpers/get-server-component-feature-flag';
|
||||||
|
import { FeatureFlagProvider } from '~/providers/feature-flag';
|
||||||
import { ThemeProvider } from '~/providers/next-theme';
|
import { ThemeProvider } from '~/providers/next-theme';
|
||||||
import { PlausibleProvider } from '~/providers/plausible';
|
import { PlausibleProvider } from '~/providers/plausible';
|
||||||
|
import { PostHogPageview } from '~/providers/posthog';
|
||||||
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
@@ -37,7 +44,11 @@ export const metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const flags = await getServerComponentAllFlags();
|
||||||
|
|
||||||
|
const locale = getLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
@@ -51,15 +62,23 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
<Suspense>
|
||||||
|
<PostHogPageview />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<PlausibleProvider>
|
<LocaleProvider locale={locale}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<FeatureFlagProvider initialFlags={flags}>
|
||||||
<TooltipProvider>
|
<PlausibleProvider>
|
||||||
<TrpcProvider>{children}</TrpcProvider>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
</TooltipProvider>
|
<TooltipProvider>
|
||||||
</ThemeProvider>
|
<TrpcProvider>{children}</TrpcProvider>
|
||||||
</PlausibleProvider>
|
</TooltipProvider>
|
||||||
<Toaster />
|
</ThemeProvider>
|
||||||
|
</PlausibleProvider>
|
||||||
|
<Toaster />
|
||||||
|
</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,
|
||||||
@@ -11,7 +11,17 @@ import {
|
|||||||
import { StackAvatar } from './stack-avatar';
|
import { StackAvatar } from './stack-avatar';
|
||||||
import { StackAvatars } from './stack-avatars';
|
import { StackAvatars } from './stack-avatars';
|
||||||
|
|
||||||
export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[] }) => {
|
export type StackAvatarsWithTooltipProps = {
|
||||||
|
recipients: Recipient[];
|
||||||
|
position?: 'top' | 'bottom';
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StackAvatarsWithTooltip = ({
|
||||||
|
recipients,
|
||||||
|
position,
|
||||||
|
children,
|
||||||
|
}: StackAvatarsWithTooltipProps) => {
|
||||||
const waitingRecipients = recipients.filter(
|
const waitingRecipients = recipients.filter(
|
||||||
(recipient) => getRecipientType(recipient) === 'waiting',
|
(recipient) => getRecipientType(recipient) === 'waiting',
|
||||||
);
|
);
|
||||||
@@ -32,9 +42,10 @@ export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="flex cursor-pointer">
|
<TooltipTrigger className="flex cursor-pointer">
|
||||||
<StackAvatars recipients={recipients} />
|
{children || <StackAvatars recipients={recipients} />}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
|
||||||
|
<TooltipContent side={position}>
|
||||||
<div className="flex flex-col gap-y-5 p-1">
|
<div className="flex flex-col gap-y-5 p-1">
|
||||||
{completedRecipients.length > 0 && (
|
{completedRecipients.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
@@ -45,7 +56,7 @@ export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[
|
|||||||
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>
|
||||||
@@ -62,7 +73,7 @@ export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[
|
|||||||
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>
|
||||||
@@ -79,7 +90,7 @@ export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[
|
|||||||
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>
|
||||||
@@ -96,7 +107,7 @@ export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[
|
|||||||
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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,47 +2,26 @@
|
|||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
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();
|
// 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}>
|
||||||
<Link
|
{/* We have no other subpaths rn */}
|
||||||
href="/dashboard"
|
{/* <Link
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
|
||||||
{
|
|
||||||
'text-foreground': pathname?.startsWith('/dashboard'),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/documents"
|
href="/documents"
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 ',
|
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||||
{
|
{
|
||||||
'text-foreground': pathname?.startsWith('/documents'),
|
'text-foreground': pathname?.startsWith('/documents'),
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Documents
|
Documents
|
||||||
</Link>
|
|
||||||
{/* <Link
|
|
||||||
href="/settings/profile"
|
|
||||||
className={cn('font-medium leading-5 text-[#A1A1AA] hover:opacity-80', {
|
|
||||||
'text-primary-foreground': pathname?.startsWith('/settings'),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</Link> */}
|
</Link> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,11 +4,8 @@ import { HTMLAttributes } 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';
|
||||||
|
|
||||||
@@ -23,7 +20,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
|||||||
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 backdrop-blur',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -41,9 +38,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,11 +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 { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
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';
|
||||||
@@ -28,26 +30,29 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '~/providers/feature-flag';
|
||||||
|
|
||||||
export type ProfileDropdownProps = {
|
export type ProfileDropdownProps = {
|
||||||
user: User;
|
user: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||||
const initials =
|
|
||||||
user.name
|
|
||||||
?.split(' ')
|
|
||||||
.map((name: string) => name.slice(0, 1).toUpperCase())
|
|
||||||
.slice(0, 2)
|
|
||||||
.join('') ?? 'UK';
|
|
||||||
|
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
const isUserAdmin = isAdmin(user);
|
||||||
|
|
||||||
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
|
||||||
|
const avatarFallback = user.name
|
||||||
|
? recipientInitials(user.name)
|
||||||
|
: user.email.slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
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>
|
||||||
@@ -55,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" />
|
||||||
@@ -69,7 +87,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{IS_SUBSCRIPTIONS_ENABLED && (
|
{isBillingEnabled && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/billing" className="cursor-pointer">
|
<Link href="/settings/billing" className="cursor-pointer">
|
||||||
<CreditCard className="mr-2 h-4 w-4" />
|
<CreditCard className="mr-2 h-4 w-4" />
|
||||||
@@ -113,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">
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
|
|
||||||
export const LazyPDFViewer = dynamic(
|
|
||||||
async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
|
||||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -39,7 +39,7 @@ export const PeriodSelector = () => {
|
|||||||
params.delete('period');
|
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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,15 +7,20 @@ import { usePathname } from 'next/navigation';
|
|||||||
|
|
||||||
import { CreditCard, Key, User } from 'lucide-react';
|
import { CreditCard, Key, User } from 'lucide-react';
|
||||||
|
|
||||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
|
||||||
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 { useFeatureFlags } from '~/providers/feature-flag';
|
||||||
|
|
||||||
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();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||||
<Link href="/settings/profile">
|
<Link href="/settings/profile">
|
||||||
@@ -44,7 +49,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{IS_SUBSCRIPTIONS_ENABLED && (
|
{isBillingEnabled && (
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -7,15 +7,20 @@ import { usePathname } from 'next/navigation';
|
|||||||
|
|
||||||
import { CreditCard, Key, User } from 'lucide-react';
|
import { CreditCard, Key, User } from 'lucide-react';
|
||||||
|
|
||||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
|
||||||
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 { useFeatureFlags } from '~/providers/feature-flag';
|
||||||
|
|
||||||
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
|
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
||||||
@@ -47,7 +52,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{IS_SUBSCRIPTIONS_ENABLED && (
|
{isBillingEnabled && (
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Github } from 'lucide-react';
|
|
||||||
import { usePlausible } from 'next-plausible';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
export type CalloutProps = {
|
|
||||||
starCount?: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Callout = ({ starCount }: CalloutProps) => {
|
|
||||||
const event = usePlausible();
|
|
||||||
|
|
||||||
const onSignUpClick = () => {
|
|
||||||
const el = document.getElementById('email');
|
|
||||||
|
|
||||||
if (el) {
|
|
||||||
const { top } = el.getBoundingClientRect();
|
|
||||||
|
|
||||||
window.scrollTo({
|
|
||||||
top: top - 120,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
el.focus();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
|
||||||
onClick={onSignUpClick}
|
|
||||||
>
|
|
||||||
Get the Community Plan
|
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
|
||||||
$30/mo. forever!
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://github.com/documenso/documenso"
|
|
||||||
target="_blank"
|
|
||||||
onClick={() => event('view-github')}
|
|
||||||
>
|
|
||||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
|
||||||
<Github className="mr-2 h-5 w-5" />
|
|
||||||
Star on Github
|
|
||||||
{starCount && starCount > 0 && (
|
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
|
||||||
{starCount.toLocaleString('en-US')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Info, Loader } from 'lucide-react';
|
|
||||||
import { usePlausible } from 'next-plausible';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
|
||||||
|
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
|
||||||
|
|
||||||
export const ZClaimPlanDialogFormSchema = z.object({
|
|
||||||
name: z.string().min(3),
|
|
||||||
email: z.string().email(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TClaimPlanDialogFormSchema = z.infer<typeof ZClaimPlanDialogFormSchema>;
|
|
||||||
|
|
||||||
export type ClaimPlanDialogProps = {
|
|
||||||
className?: string;
|
|
||||||
planId: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
|
|
||||||
const params = useSearchParams();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const event = usePlausible();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<TClaimPlanDialogFormSchema>({
|
|
||||||
mode: 'onBlur',
|
|
||||||
defaultValues: {
|
|
||||||
name: params?.get('name') ?? '',
|
|
||||||
email: params?.get('email') ?? '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZClaimPlanDialogFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
|
|
||||||
try {
|
|
||||||
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
const [redirectUrl] = await Promise.all([
|
|
||||||
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
|
|
||||||
delay,
|
|
||||||
]);
|
|
||||||
|
|
||||||
event('claim-plan-pricing');
|
|
||||||
|
|
||||||
window.location.href = redirectUrl;
|
|
||||||
} catch (error) {
|
|
||||||
event('claim-plan-failed');
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: error instanceof Error ? error.message : 'Please try again later.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Claim your plan</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
We're almost there! Please enter your email address and name to claim your plan.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form
|
|
||||||
className={cn('flex flex-col gap-y-4', className)}
|
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
|
||||||
>
|
|
||||||
{params?.get('cancelled') === 'true' && (
|
|
||||||
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Info className="h-5 w-5 text-yellow-400" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<p className="text-sm leading-5 text-yellow-700">
|
|
||||||
You have cancelled the payment process. If you didn't mean to do this, please
|
|
||||||
try again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-slate-500">Name</Label>
|
|
||||||
|
|
||||||
<Input type="text" className="mt-2" {...register('name')} autoFocus />
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-1" error={errors.name} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-slate-500">Email</Label>
|
|
||||||
|
|
||||||
<Input type="email" className="mt-2" {...register('email')} />
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-1" error={errors.email} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" size="lg" disabled={isSubmitting}>
|
|
||||||
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
Claim the Community Plan ({/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
|
|
||||||
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
|
||||||
? 'Monthly'
|
|
||||||
: 'Yearly'}
|
|
||||||
)
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
import cardBeautifulFigure from '~/assets/card-beautiful-figure.png';
|
|
||||||
import cardFastFigure from '~/assets/card-fast-figure.png';
|
|
||||||
import cardSmartFigure from '~/assets/card-smart-figure.png';
|
|
||||||
|
|
||||||
export type FasterSmarterBeautifulBentoProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const FasterSmarterBeautifulBento = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: FasterSmarterBeautifulBentoProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn('relative', className)} {...props}>
|
|
||||||
<div className="absolute inset-0 -z-10 flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src={backgroundPattern}
|
|
||||||
alt="background pattern"
|
|
||||||
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
|
||||||
A 10x better signing experience.
|
|
||||||
<span className="block md:mt-0">Faster, smarter and more beautiful.</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
|
||||||
<Card className="col-span-2" degrees={45} gradient>
|
|
||||||
<CardContent className="grid grid-cols-12 gap-8 overflow-hidden p-6 lg:aspect-[2.5/1]">
|
|
||||||
<p className="col-span-12 leading-relaxed text-[#555E67] lg:col-span-6">
|
|
||||||
<strong className="block">Fast.</strong>
|
|
||||||
When it comes to sending or receiving a contract, you can count on lightning-fast
|
|
||||||
speeds.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
|
|
||||||
<Image src={cardFastFigure} alt="its fast" className="max-w-[80%] lg:max-w-none" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
|
||||||
<strong className="block">Beautiful.</strong>
|
|
||||||
Because signing should be celebrated. That’s why we care about the smallest detail in
|
|
||||||
our product.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Image src={cardBeautifulFigure} alt="its fast" className="w-full max-w-xs" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
|
||||||
<strong className="block">Smart.</strong>
|
|
||||||
Our custom templates come with smart rules that can help you save time and energy.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Image src={cardSmartFigure} alt="its fast" className="w-full max-w-[16rem]" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Github, Slack, Twitter } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn('border-t py-12', className)} {...props}>
|
|
||||||
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
|
||||||
<div>
|
|
||||||
<Link href="/">
|
|
||||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={0}></Image>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-4 text-[#8D8D8D]">
|
|
||||||
<Link
|
|
||||||
href="https://twitter.com/documenso"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<Twitter className="h-6 w-6" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://github.com/documenso/documenso"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<Github className="h-6 w-6" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://documenso.slack.com"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<Slack className="h-6 w-6" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
|
||||||
<Link
|
|
||||||
href="/pricing"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Pricing
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://status.documenso.com"
|
|
||||||
target="_blank"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Status
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="mailto:support@documenso.com"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Support
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* <Link
|
|
||||||
href="/privacy"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Privacy
|
|
||||||
</Link> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mx-auto mt-4 w-full max-w-screen-xl px-8 md:mt-12 lg:mt-24">
|
|
||||||
<p className="text-sm text-[#8D8D8D]">
|
|
||||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||