Compare commits

..

115 Commits

Author SHA1 Message Date
8c4120f0a2 fix: remove further unused code 2023-08-29 18:12:46 +10:00
9f93af6134 fix: remove unused code 2023-08-29 17:26:19 +10:00
68a5a9da1e feat: add data table actions 2023-08-29 14:33:07 +10:00
1f8d5e45e1 feat: onepage inbox 2023-08-29 14:33:05 +10:00
8fd9730e2b feat: add inbox 2023-08-29 14:31:13 +10:00
04f6df6839 Merge pull request #304 from G3root/fix-overscroll
fix: tab overflow on smaller viewport and tab scroll ux
2023-08-29 13:26:26 +10:00
ba054ae915 Merge pull request #319 from documenso/chore/reduce-refetch-time
chore: reduce open page caching time to 1 hour
2023-08-28 22:58:05 +10:00
1d1c6e5a55 chore: reduce open page caching time to 1 hour 2023-08-28 09:43:39 +00:00
bf71d2a14e Merge pull request #305 from G3root/responsive-signing-card
fix: make signing form card responsive
2023-08-28 13:07:46 +10:00
163911255e Merge pull request #308 from adithyaakrishna/feat/pr-validate
feat: pr title validator workflow
2023-08-28 12:21:36 +10:00
24e38a3bbc Merge pull request #309 from nsylke/nsylke-patch-7
feat: set min/max lengths and autocomplete for password
2023-08-28 12:21:08 +10:00
dfd714f16a Merge pull request #310 from adithyaakrishna/feat/add-sharp
feat: added sharp package for NextJS13 image optimizations
2023-08-28 12:19:33 +10:00
722081f89e fix: dependency ordering 2023-08-28 12:14:15 +10:00
f0e1df22b8 Merge pull request #312 from G3root/fix-auth
fix: authentication allowing any password
2023-08-28 12:04:07 +10:00
615cb263fb fix: authentication 2023-08-27 07:10:41 +05:30
18faaf49d9 fix: style 2023-08-27 06:26:49 +05:30
650b69ae56 feat: added sharp for image optimizations on nextjs
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-26 21:23:30 +05:30
eb4be963e3 fix: added eol to workflow file
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-26 20:57:02 +05:30
27c27743e3 chore: updated workflow name
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-26 20:57:02 +05:30
92930a2f63 feat: added pr title validator
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-26 20:57:02 +05:30
7ad3365b0e feat: add autocomplete for password managers 2023-08-26 10:22:44 -05:00
f8bf4fea36 feat: set min/max lengths for password 2023-08-26 09:53:58 -05:00
10cd8144eb Merge pull request #307 from adithyaakrishna/feat/optimize-images
chore: optimize images
2023-08-26 20:06:48 +10:00
66973a3745 chore: optimized images to save ~8mb
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-26 13:13:30 +05:30
85677bb792 fix: make signing form card responsive 2023-08-26 08:47:19 +05:30
7ae99d2038 fix: overflow and scroll ux 2023-08-26 07:22:51 +05:30
70a5105783 fix: add missing await on mail send 2023-08-25 18:33:24 +10:00
420372ac9e fix: nicer dark mode for stack avatars 2023-08-25 17:52:58 +10:00
6b00282a87 chore: support direct urls for prisma 2023-08-25 17:52:24 +10:00
dae1001cbb Merge pull request #300 from documenso/feat/update-document-flow
feat: update document flow
2023-08-25 12:11:43 +10:00
af81d99b2a refactor: remove whitespace 2023-08-25 11:43:41 +10:00
2751adc463 feat: update document flow
- Fixed z-index when dragging pre-existing fields
- Refactored document flow
- Added button spinner
- Added animation for document flow slider
- Updated drag and drop fields
- Updated document flow so it adjusts to the height of the PDF
- Updated claim plan dialog
2023-08-25 11:43:41 +10:00
396ce9f3f3 Merge pull request #295 from nsylke/nsylke-patch-6
fix: use -p cli option for next dev
2023-08-25 11:29:44 +10:00
3f4f66d878 Merge pull request #298 from adithyaakrishna/fix/dependabot
fix: dependabot workflow
2023-08-25 11:28:12 +10:00
d6751d7a26 fix: dependabot workflow
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2023-08-24 05:37:17 +00:00
0e32baff0b feat: change document view upon completion 2023-08-24 13:31:50 +10:00
f76bf4c2c7 fix: use -p cli option for next dev 2023-08-23 17:56:12 -05:00
0d8532ab6d Merge pull request #293 from documenso/feat/refactor-shared-components
refactor: extract common components into UI package
2023-08-23 14:08:23 +10:00
490d3d51e1 Merge pull request #290 from nsylke/nsylke-patch-5
feat: create robots.txt and sitemap.xml
2023-08-23 13:56:46 +10:00
2ab796910e refactor: extract common components into UI package 2023-08-23 11:22:13 +10:00
07102588be chore: resolve build errors 2023-08-23 10:57:31 +10:00
04f9422f24 feat: robots.txt & sitemap.xml 2023-08-21 21:41:19 -05:00
05a7f5e178 Merge pull request #289 from adithyaakrishna/fix/codeql
fix: fixed build command in codeql workflow
2023-08-22 12:10:45 +10:00
7bae814f96 fix: fixed build command in codeql workflow
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-22 07:26:26 +05:30
ea45e38fa0 Merge pull request #273 from documenso/feat/feature-flag
feat: feature flags
2023-08-22 10:13:51 +10:00
6d9a85112f Merge pull request #286 from documenso/feat/blog-post-next
feat: add blog post 'Preview the next Documenso'
2023-08-22 10:07:43 +10:00
346efd19db Merge pull request #284 from adithyaakrishna/feat/general-updates
feat: added codeql and dependabot workflows
2023-08-22 10:06:39 +10:00
617143a47f fix: removed ts from codeql
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-21 22:42:45 +05:30
66f067276e Add visual asset for blog post 2023-08-21 17:24:29 +02:00
fc10d0449f Create blog post 2023-08-21 17:23:47 +02:00
083f3e7108 feat: add codeql-analysis
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-21 19:08:47 +05:30
af307a2a49 chore: updated dependabot config
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-21 19:03:53 +05:30
b063758ee5 chore: update readme file with radix-ui
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-21 19:03:26 +05:30
4964b252e3 chore: update readme file
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-21 18:56:48 +05:30
e468f5bbc9 chore: enable job concurrency
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-21 18:55:53 +05:30
c5b7b8a18a feat: add dependabot config
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-21 18:54:59 +05:30
a8a1fbb829 feat: Added Engines to Enforce Node v18.0.0 and above
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-21 18:54:44 +05:30
3c2a4892e7 Merge pull request #283 from documenso/chore/shop
chore: add shop to footer
2023-08-21 14:18:18 +02:00
6d9e84d327 chore: add shop to footer 2023-08-21 14:16:19 +02:00
73b4e30c97 Merge pull request #257 from documenso/feat/onepage
Feat/onepage
2023-08-21 13:33:04 +02:00
bd01545a70 Merge pull request #271 from premiare/feat/mobileNavigation
feat: Mobile Navigation
2023-08-21 20:01:08 +10:00
6d360e581d fix: styling updates 2023-08-21 19:56:18 +10:00
ba95818da4 feat: update items 2023-08-21 12:17:56 +10:00
d0720f4c70 feat: update items
Refactored billing flag name

Refactored FeatureFlag type

Disabled session recording by default
2023-08-21 12:17:56 +10:00
f60cb22f11 feat: feature flags 2023-08-21 12:17:56 +10:00
e0cb4314fb Merge pull request #280 from nsylke/nsylke-patch-4
chore: ignore intellij editor settings and some of vscode
2023-08-19 10:53:11 +10:00
0571137a60 chore: ignore intellij editor settings and some of vscode 2023-08-18 18:41:06 -05:00
30aabf50eb Merge pull request #279 from documenso/feat/funding
chore: remove funding rounding
2023-08-18 17:50:51 +02:00
8441a5eb98 Merge branch 'feat/refresh' into feat/funding 2023-08-18 17:48:31 +02:00
259ab49bc1 chore: remove funding rounding 2023-08-18 17:47:36 +02:00
2f2d5dfc0b Merge branch 'feat/refresh' into feat/mobileNavigation 2023-08-18 19:57:26 +10:00
0f27f4261b add PORT number back to package.json 2023-08-18 19:37:07 +10:00
9b92cad2db move menu links to map, add window-size hook 2023-08-18 19:35:45 +10:00
ad1ff6159c add reducedMotion check, center menu items 2023-08-18 19:05:46 +10:00
f633b17f17 Merge pull request #270 from Ashutosh-Bhadauriya/feat/add-commitlint
feat: add commitlint
2023-08-18 18:38:31 +10:00
8fa16001e6 fix: commitlint 2023-08-18 13:49:46 +05:30
e111234460 feat: add commitlint 2023-08-18 13:26:01 +05:30
034072f50e Merge branch 'feat/refresh' into feat/onepage 2023-08-18 12:15:36 +10:00
a7664d79fd Merge pull request #260 from documenso/feat/document-authoring
feat: document authoring
2023-08-18 12:10:35 +10:00
8ed2393300 Merge branch 'feat/refresh' into feat/document-authoring 2023-08-18 12:07:50 +10:00
94cf150ffd Merge pull request #258 from nsylke/nsylke-patch-3
ci: create build workflow
2023-08-18 09:32:44 +10:00
c571a3d0d9 ci: use built-in cache from setup-node action 2023-08-17 18:17:27 -05:00
1c7431b859 Merge pull request #264 from documenso/fix/blog-post-pre-seed
Fix: typo in blog post `pre-seed`
2023-08-18 08:59:21 +10:00
2f7d6548ef Merge pull request #259 from documenso/feat/pie-chart-legend
feat: add legend to pie chart
2023-08-18 08:55:49 +10:00
a16525be5e fix typo 2023-08-17 17:20:55 +02:00
21e377d3ff fix typo for consistency 2023-08-17 16:46:42 +02:00
45d0d3f7e8 extract hamburger to own file 2023-08-18 00:18:25 +10:00
6e62eb8d81 init mobileNavigation and added documenso symbol 2023-08-18 00:12:13 +10:00
e098449af4 Merge pull request #262 from documenso/feat/funding
fix: link
2023-08-17 13:02:38 +02:00
47d0030cf0 fix: link 2023-08-17 13:02:21 +02:00
b564e5e72f feat: reduce chart radius to add padding 2023-08-17 10:57:36 +00:00
e46607c1cb Merge pull request #261 from documenso/feat/funding
Feat/funding
2023-08-17 12:56:04 +02:00
77c5db169a fix: format heade 2023-08-17 12:54:40 +02:00
0157bf9576 chore: adding open to footer, naming 2023-08-17 12:50:31 +02:00
454c2f45bd fix: open page link 2023-08-17 12:46:24 +02:00
badb897c06 chore: update date 2023-08-17 12:41:55 +02:00
9bb5768598 Merge branch 'feat/refresh' into feat/funding 2023-08-17 12:39:37 +02:00
951de8baf5 chore: review updates 2023-08-17 12:37:58 +02:00
48ceb1e5c7 feat: document authoring 2023-08-17 19:56:18 +10:00
96e8962956 feat: change legend text color to black 2023-08-17 09:44:43 +00:00
f3259aedea feat: Add legend to pie chart 2023-08-17 08:24:37 +00:00
9fdc9dcbf7 Merge pull request #256 from nsylke/nsylke-patch-2
Add husky & lint staged
2023-08-17 11:07:18 +10:00
0a30403719 ci: remove --workspaces from the build script in ci workflow 2023-08-16 20:00:13 -05:00
0e1fcd86e6 ci: setup build workflow 2023-08-16 19:51:45 -05:00
8038f3ad00 fix: used wrong lockfile version when resolving conflicts 2023-08-16 13:00:50 -05:00
44369ee7f6 mention cals open startup article 2023-08-16 17:52:04 +02:00
7e46cb0d8e feat: pre seed announce and link on /open page 2023-08-16 17:42:27 +02:00
10e39246ce Merge branch 'feat/refresh' into nsylke-patch-2
# Conflicts:
#	package-lock.json
2023-08-16 07:34:40 -05:00
5e45767e44 Merge pull request #249 from documenso/feat/open-page
feat: add cap table to open page
2023-08-16 14:09:01 +02:00
3b9c57fe5c chore: remove double emtpy state add CTA 2023-08-16 13:49:19 +02:00
6017d35cfd refactor: future proofing the prettier/lint-staged for js/ts filetypes 2023-08-16 06:27:45 -05:00
90e28cd3a4 chore: status order to figma 2023-08-16 09:29:16 +02:00
e743e56787 remove dashboard and link bar 2023-08-16 09:03:28 +02:00
ba25ea1370 refactor: use lint-staged.config.cjs as configuration for lint-staged 2023-08-15 16:02:58 -05:00
f0c607d87a add husky and lint-staged to ensure commits are formatted 2023-08-14 16:14:53 -05:00
181 changed files with 120443 additions and 1711 deletions

View File

@ -12,6 +12,8 @@ NEXT_PUBLIC_APP_URL="http://localhost:3000"
# [[DATABASE]]
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# [[SMTP]]
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
@ -52,7 +54,12 @@ NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
# [[FEATURES]]
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false
# OPTIONAL: Leave blank to disable PostHog and feature flags.
NEXT_PUBLIC_POSTHOG_KEY=""
# OPTIONAL: Defines the host to use for PostHog.
NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com"
# OPTIONAL: Leave blank to disable billing.
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# This is only required for the marketing site
# [[REDIS]]

32
.github/dependabot.yml vendored Normal file
View 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

33
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,33 @@
name: "Continuous Integration"
on:
push:
branches: [ "feat/refresh" ]
pull_request:
branches: [ "feat/refresh" ]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
env:
HUSKY: 0
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 2
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build

45
.github/workflows/codeql-analysis.yml vendored Normal file
View 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

View 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
View File

@ -37,3 +37,13 @@ yarn-error.log*
# contentlayer
.contentlayer
# intellij
.idea
# vscode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

4
.husky/commit-msg Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run commitlint -- $1

4
.husky/pre-commit Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

View File

@ -89,6 +89,10 @@ Documenso is built using awesome open source tech including:
- [Node SignPDF (Digital Signature)](https://github.com/vbuch/node-signpdf)
- [React-PDF for viewing PDFs](https://github.com/wojtekmaj/react-pdf)
- [PDF-Lib for PDF manipulation](https://github.com/Hopding/pdf-lib)
- [Zod for schema declaration and validation](https://zod.dev/)
- [Lucide React for icons in React app](https://lucide.dev/)
- [Framer Motion for motion library](https://www.framer.com/motion/)
- [Radix UI for component library](https://www.radix-ui.com/)
- Check out `/package.json` and `/apps/web/package.json` for more
- Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned.

View File

@ -0,0 +1,56 @@
---
title: Preview the next Documenso
description: We're redesigning Documenso by making it more elegant and appropriately playful. Here's a sneak peek.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2023-08-21
tags:
- Design
- Preview
---
Since we launched [Documenso 0.9 on Product Hunt](https://producthunt.com/products/documenso#documenso) last May, the team's been hard at work behind the scenes to ramp up development and design to deliver an excellent next version.
Last week, Lucas shared the reasoning how [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
Today, I'm pleased to share with you a preview of the next Documenso.
## Preview the next Documenso
We redesigned the whole signing flow to make it more appealing and more convenient.
We improved the overall look and feel by making it more elegant and appropriately playful. Focused on the task at hand, but explicitly enjoying doing it.
**We call it happy minimalism.**
We paid particular attention to the moment of signing, which should be celebrated.
The image below is the final bloom of the completion celebration we added:
<figure>
<MdxNextImage
src="/blog/blog-fig-preview-documenso.webp"
width="2000"
height="1268"
alt="Figure 1"
/>
<figcaption className="text-center">"You've signed a new document."</figcaption>
</figure>
## Kicking off a new phase of collaboration
This preview also is the kickoff for a new phase of how we collaborate with the community.
We recently [switched to Discord](https://documenso.com/blog/switching-from-slack-to-discord) to set up a more developer-friendly, community-driven environment, and we just released the [public roadmap](https://documen.so/launches).
As always, if you have any questions or feedback, please reach out. We love to hear from you.
Best from Hamburg,
Timur
Make sure to [star the GitHub repository](https://documen.so/github), [follow us on X](http://documen.so/twitter) and [join the Discord server](https://documen.so/discord) to keep up to date with all things Documenso.
We're building a beautiful, open-source alternative to DocuSign.

View File

@ -0,0 +1,36 @@
---
title: Announcing Pre-Seed and Open Metrics
description: We are exicited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2023-08-17
tags:
- Funding
- Metrics
- Open Startup
---
Today I'm happy to announce that we closed a \$1.25M Pre-Seed round for Documenso, bringing our total funding to \$1.54M. The round actually closed last month, we just were sneaky about it.
## Two more for the road (to open signing)
We're ecstatic to welcome [OSS Capital](https://twitter.com/osscapital) and especially [Joseph Jacks](https://twitter.com/JosephJacks_) to the inner circle of the open signing revolution. We're also fortunate to be joined by Orrick's very own [John Harrison](https://www.linkedin.com/in/john-harrison-a1213b9/) and his legal experience. For those who are wondering, yes, the round was, of course, signed using Documenso.
## Open Source, Open Metrics
If you follow us, you know we're firmly committed to the open source values of openness and transparency. For us, this includes not only the code side of things but also the business. As we aim to build trust among our investors, customers, and partners, we want to be open about what's going on. We also want to allow everyone to learn from our data and choices, just as we did from so many other COSS (Commercial Open Source) startups. The term "Open Startup" isn't precisely defined (and probably will never be, just like startup). There is however a [great write-up](https://cal.com/blog/open-startup) about the basics by the founder of our favorite open source scheduling tool Cal.com.
The two main takeaways are:
- "Any Startup that shares its metrics as open as technically and operationally possible is an Open Startup."
- "Why should I care? Frankly speaking, Open Startups have a tough time screwing you over."
The more open the culture, the less shady stuff is going on. While this may sound trivial, the implications are profound. A new generation of organizations, operating more ethically and responsibly simply because everything is out in the open.
For us, there are two sides to being an Open Startup:
- The company side: Sharing Financial KPIs like growth, funding, team structure, salary, internal processes, and tools.
- The product side: Sharing insights and data like usage, reach, and GitHub activity.
Both sides aim to contribute to the global knowledge base of how startups work, specifically COSS startups. As we see more and more COSS, best practices and business insights should be broadly available to let the space mature. As we contribute code to the global community, we also contribute our business knowledge to help bring about even more COSS.
Starting today, we're releasing a lot of our data as part of the Open Startup movement. You can find the juicy details on our funding and more here: [documen.so/open](https://documen.so/open)

View File

@ -4,10 +4,11 @@
"private": true,
"license": "AGPL-3.0",
"scripts": {
"dev": "PORT=3001 next dev",
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
},
"dependencies": {
"@documenso/lib": "*",
@ -29,6 +30,7 @@
"react-hook-form": "^7.43.9",
"react-icons": "^4.8.0",
"recharts": "^2.7.2",
"sharp": "0.32.5",
"typescript": "5.1.6",
"zod": "^3.21.4"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

56611
apps/marketing/public/pdf.worker.min.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
import { HTMLAttributes, useEffect, useState } from 'react';
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts';
import { cn } from '@documenso/ui/lib/utils';
@ -53,30 +53,33 @@ export const CapTable = ({ className, ...props }: CapTableProps) => {
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border shadow-sm hover:shadow">
{!isSSR && (
<ResponsiveContainer width="100%" height={400}>
<PieChart>
<Pie
data={CAP_TABLE}
cx="50%"
cy="50%"
labelLine={false}
label={renderCustomizedLabel}
outerRadius={160}
innerRadius={80}
fill="#8884d8"
dataKey="percentage"
>
{CAP_TABLE.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
formatter={(percent: number, name, props) => {
return [`${percent}%`, name || props['name'] || props['payload']['name']];
}}
/>
</PieChart>
</ResponsiveContainer>
<PieChart width={400} height={400}>
<Pie
data={CAP_TABLE}
cx="50%"
cy="50%"
labelLine={false}
label={renderCustomizedLabel}
outerRadius={160}
innerRadius={80}
fill="#8884d8"
dataKey="percentage"
>
{CAP_TABLE.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Legend
formatter={(value) => {
return <span className="text-sm text-black">{value}</span>;
}}
/>
<Tooltip
formatter={(percent: number, name, props) => {
return [`${percent}%`, name || props['name'] || props['payload']['name']];
}}
/>
</PieChart>
)}
</div>
</div>

View File

@ -56,11 +56,11 @@ export const FUNDING_RAISED = [
},
{
date: '2023-05',
amount: 300_000,
amount: 290_000,
},
{
date: '2023-07',
amount: 1_550_000,
amount: 1_540_000,
},
];

View File

@ -8,7 +8,7 @@ import { FundingRaised } from './funding-raised';
import { GithubMetric } from './gh-metrics';
import { TeamMembers } from './team-members';
export const revalidate = 86400;
export const revalidate = 3600;
const ZGithubStatsResponse = z.object({
stargazers_count: z.number(),
@ -70,9 +70,12 @@ export default async function OpenPage() {
<div className="flex flex-col items-center justify-center">
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal">
<p className="text-muted-foreground mt-4 max-w-[60ch] text-center text-lg leading-normal">
All our metrics, finances, and learnings are public. We believe in transparency and want
to share our journey with you.
to share our journey with you. You can read more about why here:{' '}
<a className="font-bold" href="https://documenso.com/blog/pre-seed" target="_blank">
Announcing Open Metrics
</a>
</p>
</div>

View 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`,
};
}

View 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,
},
];
}

View File

@ -5,7 +5,7 @@ import React, { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Info, Loader } from 'lucide-react';
import { Info } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@ -85,7 +85,7 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
@ -97,50 +97,49 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
</DialogDescription>
</DialogHeader>
<form
className={cn('flex flex-col gap-y-4', className)}
onSubmit={handleSubmit(onFormSubmit)}
>
{params?.get('cancelled') === 'true' && (
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<Info className="h-5 w-5 text-yellow-400" />
</div>
<div className="ml-3">
<p className="text-sm leading-5 text-yellow-700">
You have cancelled the payment process. If you didn't mean to do this, please
try again.
</p>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting} className={cn('flex flex-col gap-y-4', className)}>
{params?.get('cancelled') === 'true' && (
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<Info className="h-5 w-5 text-yellow-400" />
</div>
<div className="ml-3">
<p className="text-sm leading-5 text-yellow-700">
You have cancelled the payment process. If you didn't mean to do this, please
try again.
</p>
</div>
</div>
</div>
)}
<div>
<Label className="text-slate-500">Name</Label>
<Input type="text" className="mt-2" {...register('name')} autoFocus />
<FormErrorMessage className="mt-1" error={errors.name} />
</div>
)}
<div>
<Label className="text-slate-500">Name</Label>
<div>
<Label className="text-slate-500">Email</Label>
<Input type="text" className="mt-2" {...register('name')} autoFocus />
<Input type="email" className="mt-2" {...register('email')} />
<FormErrorMessage className="mt-1" error={errors.name} />
</div>
<FormErrorMessage className="mt-1" error={errors.email} />
</div>
<div>
<Label className="text-slate-500">Email</Label>
<Input type="email" className="mt-2" {...register('email')} />
<FormErrorMessage className="mt-1" error={errors.email} />
</div>
<Button type="submit" size="lg" disabled={isSubmitting}>
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
Claim the Community Plan ({/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
? 'Monthly'
: 'Yearly'}
)
</Button>
<Button type="submit" size="lg" loading={isSubmitting}>
Claim the Community Plan (
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
? 'Monthly'
: 'Yearly'}
)
</Button>
</fieldset>
</form>
</DialogContent>
</Dialog>

View File

@ -3,7 +3,7 @@ import { HTMLAttributes } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Github, Slack, Twitter } from 'lucide-react';
import { Github, MessagesSquare, Twitter } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
@ -36,20 +36,16 @@ export const Footer = ({ className, ...props }: FooterProps) => {
</Link>
<Link
href="https://documenso.slack.com"
href="https://documen.so/discord"
target="_blank"
className="hover:text-[#6D6D6D]"
>
<Slack className="h-6 w-6" />
<MessagesSquare className="h-6 w-6" />
</Link>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
<Link href="/blog" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
Blog
</Link>
<Link
href="/pricing"
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
@ -57,6 +53,22 @@ export const Footer = ({ className, ...props }: FooterProps) => {
Pricing
</Link>
<Link href="/blog" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
Blog
</Link>
<Link href="/open" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
Open
</Link>
<Link
href="https://shop.documenso.com"
target="_blank"
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
>
Shop
</Link>
<Link
href="https://status.documenso.com"
target="_blank"

View File

@ -1,26 +1,37 @@
import { HTMLAttributes } from 'react';
'use client';
import { HTMLAttributes, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { cn } from '@documenso/ui/lib/utils';
import { HamburgerMenu } from './mobile-hamburger';
import { MobileNavigation } from './mobile-navigation';
export type HeaderProps = HTMLAttributes<HTMLElement>;
export const Header = ({ className, ...props }: HeaderProps) => {
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
return (
<header className={cn('flex items-center justify-between', className)} {...props}>
<Link href="/">
<Image src="/logo.png" alt="Documenso Logo" width={170} height={0}></Image>
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
</Link>
<div className="flex items-center gap-x-6">
<div className="hidden items-center gap-x-6 md:flex">
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
Pricing
</Link>
<Link href="/blog" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
Blog
</Link>
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
Pricing
<Link href="/open" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
Open
</Link>
<Link
@ -31,6 +42,15 @@ export const Header = ({ className, ...props }: HeaderProps) => {
Sign in
</Link>
</div>
<HamburgerMenu
onToggleMenuOpen={() => setIsHamburgerMenuOpen((v) => !v)}
isMenuOpen={isHamburgerMenuOpen}
/>
<MobileNavigation
isMenuOpen={isHamburgerMenuOpen}
onMenuOpenChange={setIsHamburgerMenuOpen}
/>
</header>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1,121 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { motion, useReducedMotion } from 'framer-motion';
import { Github, MessagesSquare, Twitter } from 'lucide-react';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
export type MobileNavigationProps = {
isMenuOpen: boolean;
onMenuOpenChange?: (_value: boolean) => void;
};
export const MENU_NAVIGATION_LINKS = [
{
href: '/blog',
text: 'Blog',
},
{
href: '/pricing',
text: 'Pricing',
},
{
href: 'https://status.documenso.com',
text: 'Status',
},
{
href: 'mailto:support@documenso.com',
text: 'Support',
},
{
href: '/privacy',
text: 'Privacy',
},
{
href: 'https://app.documenso.com/login',
text: 'Sign in',
},
];
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
const shouldReduceMotion = useReducedMotion();
const handleMenuItemClick = () => {
onMenuOpenChange?.(false);
};
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<SheetContent className="w-full max-w-[400px]">
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
</Link>
<motion.div
className="mt-12 flex w-full flex-col items-start gap-y-4"
initial="initial"
animate="animate"
transition={{
staggerChildren: 0.2,
}}
>
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
<motion.div
key={href}
variants={{
initial: {
opacity: 0,
x: shouldReduceMotion ? 0 : 100,
},
animate: {
opacity: 1,
x: 0,
transition: {
duration: 0.5,
},
},
}}
>
<Link
className="text-2xl font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]"
href={href}
onClick={() => handleMenuItemClick()}
>
{text}
</Link>
</motion.div>
))}
</motion.div>
<div className="mx-auto mt-8 flex w-full flex-wrap items-center gap-x-4 gap-y-4 ">
<Link
href="https://twitter.com/documenso"
target="_blank"
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
>
<Twitter className="h-6 w-6" />
</Link>
<Link
href="https://github.com/documenso/documenso"
target="_blank"
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
>
<Github className="h-6 w-6" />
</Link>
<Link
href="https://documen.so/discord"
target="_blank"
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
>
<MessagesSquare className="h-6 w-6" />
</Link>
</div>
</SheetContent>
</Sheet>
);
};

View File

@ -21,12 +21,12 @@ import {
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { claimPlan } from '~/api/claim-plan/fetcher';
import { FormErrorMessage } from '../form/form-error-message';
import { SignaturePad } from '../signature-pad';
const ZWidgetFormSchema = z
.object({

View File

@ -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>
);
};

View 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;
}

View File

@ -9,16 +9,10 @@
}
],
"paths": {
"~/*": [
"./src/*"
],
"contentlayer/generated": [
"./.contentlayer/generated"
]
"~/*": ["./src/*"],
"contentlayer/generated": ["./.contentlayer/generated"]
},
"types": [
"@documenso/lib/types/next-auth.d.ts"
],
"types": ["@documenso/lib/types/next-auth.d.ts"],
"strictNullChecks": true,
"incremental": false
},
@ -29,7 +23,5 @@
".next/types/**/*.ts",
".contentlayer/generated"
],
"exclude": [
"node_modules"
]
"exclude": ["node_modules"]
}

View File

@ -4,10 +4,11 @@
"private": true,
"license": "AGPL-3.0",
"scripts": {
"dev": "PORT=3000 next dev",
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
},
"dependencies": {
"@documenso/lib": "*",
@ -20,6 +21,7 @@
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
"lucide-react": "^0.214.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"nanoid": "^4.0.2",
"next": "13.4.12",
@ -27,18 +29,22 @@
"next-plausible": "^3.10.1",
"next-themes": "^0.2.1",
"perfect-freehand": "^1.2.0",
"posthog-js": "^1.75.3",
"posthog-node": "^3.1.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.43.9",
"react-icons": "^4.8.0",
"react-pdf": "^7.1.1",
"react-rnd": "^10.4.1",
"sharp": "0.32.5",
"ts-pattern": "^5.0.5",
"typescript": "5.1.6",
"zod": "^3.21.4"
},
"devDependencies": {
"@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7"

View File

@ -10,8 +10,6 @@ declare namespace NodeJS {
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: string;
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
}

56611
apps/web/public/pdf.worker.min.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -22,14 +22,14 @@ import { LocaleDate } from '~/components/formatter/locale-date';
import { UploadDocument } from './upload-document';
export default async function DashboardPage() {
const session = await getRequiredServerComponentSession();
const user = await getRequiredServerComponentSession();
const [stats, results] = await Promise.all([
getStats({
userId: session.id,
user,
}),
findDocuments({
userId: session.id,
userId: user.id,
perPage: 10,
}),
]);

View File

@ -5,10 +5,10 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCreateDocument } from '~/api/document/create/fetcher';
import { DocumentDropzone } from '~/components/(dashboard)/document-dropzone/document-dropzone';
export type UploadDocumentProps = {
className?: string;

View File

@ -2,28 +2,28 @@
import { useState } from 'react';
import dynamic from 'next/dynamic';
import { Loader } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AddFieldsFormPartial } from '~/components/forms/edit-document/add-fields';
import { AddSignersFormPartial } from '~/components/forms/edit-document/add-signers';
import { AddSubjectFormPartial } from '~/components/forms/edit-document/add-subject';
const PDFViewer = 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>
),
});
import { addFields } from '~/components/forms/edit-document/add-fields.action';
import { addSigners } from '~/components/forms/edit-document/add-signers.action';
import { completeDocument } from '~/components/forms/edit-document/add-subject.action';
export type EditDocumentFormProps = {
className?: string;
@ -33,6 +33,8 @@ export type EditDocumentFormProps = {
fields: Field[];
};
type EditDocumentStep = 'signers' | 'fields' | 'subject';
export const EditDocumentForm = ({
className,
document,
@ -40,27 +42,103 @@ export const EditDocumentForm = ({
fields,
user: _user,
}: EditDocumentFormProps) => {
const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers');
const { toast } = useToast();
const router = useRouter();
const [step, setStep] = useState<EditDocumentStep>('signers');
const documentUrl = `data:application/pdf;base64,${document.document}`;
const onNextStep = () => {
if (step === 'signers') {
setStep('fields');
}
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
signers: {
title: 'Add Signers',
description: 'Add the people who will sign the document.',
stepIndex: 1,
onSubmit: () => onAddSignersFormSubmit,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 2,
onBackStep: () => setStep('signers'),
onSubmit: () => onAddFieldsFormSubmit,
},
subject: {
title: 'Add Subject',
description: 'Add the subject and message you wish to send to signers.',
stepIndex: 3,
onBackStep: () => setStep('fields'),
onSubmit: () => onAddSubjectFormSubmit,
},
};
if (step === 'fields') {
setStep('subject');
const currentDocumentFlow = documentFlow[step];
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try {
// Custom invocation server action
await addSigners({
documentId: document.id,
signers: data.signers,
});
router.refresh();
setStep('fields');
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
};
const onPreviousStep = () => {
if (step === 'fields') {
setStep('signers');
}
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
try {
// Custom invocation server action
await addFields({
documentId: document.id,
fields: data.fields,
});
if (step === 'subject') {
setStep('fields');
router.refresh();
setStep('subject');
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message } = data.email;
try {
await completeDocument({
documentId: document.id,
email: {
subject,
message,
},
});
router.refresh();
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while sending the document.',
variant: 'destructive',
});
}
};
@ -71,40 +149,48 @@ export const EditDocumentForm = ({
gradient
>
<CardContent className="p-2">
<PDFViewer document={documentUrl} />
<LazyPDFViewer document={documentUrl} />
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
{step === 'signers' && (
<AddSignersFormPartial
recipients={recipients}
fields={fields}
document={document}
onContinue={onNextStep}
onGoBack={onPreviousStep}
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
)}
{step === 'fields' && (
<AddFieldsFormPartial
recipients={recipients}
fields={fields}
document={document}
onContinue={onNextStep}
onGoBack={onPreviousStep}
/>
)}
{step === 'signers' && (
<AddSignersFormPartial
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddSignersFormSubmit}
/>
)}
{step === 'subject' && (
<AddSubjectFormPartial
recipients={recipients}
fields={fields}
document={document}
onContinue={onNextStep}
onGoBack={onPreviousStep}
/>
)}
{step === 'fields' && (
<AddFieldsFormPartial
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddFieldsFormSubmit}
/>
)}
{step === 'subject' && (
<AddSubjectFormPartial
documentFlow={documentFlow.subject}
document={document}
recipients={recipients}
fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddSubjectFormSubmit}
/>
)}
</DocumentFlowFormContainer>
</div>
</div>
);

View File

@ -1,34 +1,19 @@
'use client';
import dynamic from 'next/dynamic';
import { Loader } from 'lucide-react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewerProps } from '~/components/(dashboard)/pdf-viewer/pdf-viewer';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewerProps } from '@documenso/ui/primitives/pdf-viewer';
export type LoadablePDFCard = PDFViewerProps & {
className?: string;
pdfClassName?: string;
};
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
ssr: false,
loading: () => (
<div className="flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
<Loader className="h-12 w-12 animate-spin text-slate-500" />
<p className="mt-4 text-slate-500">Loading document...</p>
</div>
),
});
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
return (
<Card className={className} gradient {...props}>
<CardContent className="p-2">
<PDFViewer className={pdfClassName} {...props} />
<LazyPDFViewer className={pdfClassName} {...props} />
</CardContent>
</Card>
);

View File

@ -9,12 +9,13 @@ export default function Loading() {
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
</Link>
<h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl">
Loading Document...
</h1>
<div className="mt-8 grid min-h-[80vh] w-full grid-cols-12 gap-x-8">
<div className="mt-8 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
<div className="flex min-h-[80vh] flex-col items-center justify-center">
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p>

View File

@ -7,8 +7,11 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
export type DocumentPageProps = {
@ -69,18 +72,28 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<span>{recipients.length} Recipient(s)</span>
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
<EditDocumentForm
className="mt-8"
document={document}
user={session}
recipients={recipients}
fields={fields}
/>
{document.status !== InternalDocumentStatus.COMPLETED && (
<EditDocumentForm
className="mt-8"
document={document}
user={session}
recipients={recipients}
fields={fields}
/>
)}
{document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer document={`data:application/pdf;base64,${document.document}`} />
</div>
)}
</div>
);
}

View File

@ -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>
));
};

View File

@ -0,0 +1,133 @@
'use client';
import Link from 'next/link';
import {
Copy,
Download,
Edit,
History,
MoreHorizontal,
Pencil,
Share,
Trash2,
XCircle,
} from 'lucide-react';
import { useSession } from 'next-auth/react';
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
export type DataTableActionDropdownProps = {
row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
};
};
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
if (!session) {
return null;
}
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id;
// const isRecipient = !!recipient;
// const isDraft = row.status === DocumentStatus.DRAFT;
// const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const onDownloadClick = () => {
let decodedDocument = row.document;
try {
decodedDocument = atob(decodedDocument);
} catch (err) {
// We're just going to ignore this error and try to download the document
console.error(err);
}
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
const blob = new Blob([documentBytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = row.title || 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
};
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="h-5 w-5 text-gray-500" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!recipient} asChild>
<Link href={`/sign/${recipient?.token}`}>
<Pencil className="mr-2 h-4 w-4" />
Sign
</Link>
</DropdownMenuItem>
<DropdownMenuItem disabled={!isOwner} asChild>
<Link href={`/documents/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem disabled>
<XCircle className="mr-2 h-4 w-4" />
Void
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel>
<DropdownMenuItem disabled>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Share className="mr-2 h-4 w-4" />
Share
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@ -8,7 +8,7 @@ import { Loader } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { FindResultSet } from '@documenso/lib/types/find-result-set';
import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient';
import { Document, Recipient, User } from '@documenso/prisma/client';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@ -16,8 +16,16 @@ import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-a
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
import { DataTableActionButton } from './data-table-action-button';
import { DataTableActionDropdown } from './data-table-action-dropdown';
export type DocumentsDataTableProps = {
results: FindResultSet<DocumentWithReciepient>;
results: FindResultSet<
Document & {
Recipient: Recipient[];
User: Pick<User, 'id' | 'name' | 'email'>;
}
>;
};
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
@ -45,7 +53,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
{
header: 'Title',
cell: ({ row }) => (
<Link href={`/documents/${row.original.id}`} className="font-medium hover:underline">
<Link
href={`/documents/${row.original.id}`}
title={row.original.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.original.title}
</Link>
),
@ -67,6 +79,15 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
accessorKey: 'created',
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
},
{
header: 'Actions',
cell: ({ row }) => (
<div className="flex items-center gap-x-4">
<DataTableActionButton row={row.original} />
<DataTableActionDropdown row={row.original} />
</div>
),
},
]}
data={results.data}
perPage={results.perPage}

View File

@ -1,28 +1,22 @@
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { isDocumentStatus } from '@documenso/lib/types/is-document-status';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { DocumentDropzone } from '~/components/(dashboard)/document-dropzone/document-dropzone';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import {
PeriodSelectorValue,
isPeriodSelectorValue,
} from '~/components/(dashboard)/period-selector/types';
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
import { UploadDocument } from '../dashboard/upload-document';
import { DocumentsDataTable } from './data-table';
export type DocumentsPageProps = {
searchParams?: {
status?: InternalDocumentStatus | 'ALL';
status?: ExtendedDocumentStatus;
period?: PeriodSelectorValue;
page?: string;
perPage?: string;
@ -30,20 +24,20 @@ export type DocumentsPageProps = {
};
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
const session = await getRequiredServerComponentSession();
const user = await getRequiredServerComponentSession();
const stats = await getStats({
userId: session.id,
user,
});
const status = isDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
// const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const results = await findDocuments({
userId: session.id,
status: status === 'ALL' ? undefined : status,
userId: user.id,
status,
orderBy: {
column: 'created',
direction: 'desc',
@ -52,8 +46,6 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
perPage,
});
const isNoResults = status === 'ALL' && period === '' && results.data.length === 0;
const getTabHref = (value: typeof status) => {
const params = new URLSearchParams(searchParams);
@ -63,73 +55,47 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
params.delete('page');
}
if (value === 'ALL') {
params.delete('status');
}
return `/documents?${params.toString()}`;
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">All Documents</h1>
<UploadDocument />
<h1 className="mt-12 text-4xl font-semibold">Documents</h1>
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
<Tabs defaultValue={status}>
<Tabs defaultValue={status} className="overflow-x-auto">
<TabsList>
<TabsTrigger className="min-w-[60px]" value="ALL" asChild>
<Link href={getTabHref('ALL')}>All</Link>
</TabsTrigger>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
<Link href={getTabHref(value)} scroll={false}>
<DocumentStatus status={value} />
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.DRAFT} asChild>
<Link href={getTabHref(InternalDocumentStatus.DRAFT)}>
<DocumentStatus status={InternalDocumentStatus.DRAFT} />
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats.DRAFT, 99)}
</span>
</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.PENDING} asChild>
<Link href={getTabHref(InternalDocumentStatus.PENDING)}>
<DocumentStatus status={InternalDocumentStatus.PENDING} />
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats.PENDING, 99)}
</span>
</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.COMPLETED} asChild>
<Link href={getTabHref(InternalDocumentStatus.COMPLETED)}>
<DocumentStatus status={InternalDocumentStatus.COMPLETED} />
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats.COMPLETED, 99)}
</span>
</Link>
</TabsTrigger>
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats[value], 99)}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex flex-1 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
<Button>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Document
</Button>
</div>
</div>
<div className="mt-8">
{/* If we're viewing all documents for all time and there's nuffin we should should an add document component instead */}
{isNoResults ? (
<DocumentDropzone className="min-h-[60vh] md:min-h-[40vh]" />
) : (
<DocumentsDataTable results={results} />
)}
<DocumentsDataTable results={results} />
</div>
</div>
);

View File

@ -8,6 +8,7 @@ import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Header } from '~/components/(dashboard)/layout/header';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
import { NextAuthProvider } from '~/providers/next-auth';
export type AuthenticatedDashboardLayoutProps = {
@ -30,6 +31,8 @@ export default async function AuthenticatedDashboardLayout({
<Header user={user} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
<RefreshOnFocus />
</NextAuthProvider>
);
}

View File

@ -1,15 +1,17 @@
import { redirect } from 'next/navigation';
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { PasswordForm } from '~/components/forms/password';
import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
export default async function BillingSettingsPage() {
const user = await getRequiredServerComponentSession();
const isBillingEnabled = await getServerComponentFlag('app_billing');
// Redirect if subscriptions are not enabled.
if (!IS_SUBSCRIPTIONS_ENABLED) {
if (!isBillingEnabled) {
redirect('/settings/profile');
}

View File

@ -0,0 +1,68 @@
'use client';
import { HTMLAttributes } from 'react';
import { Download } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
disabled?: boolean;
fileName?: string;
document?: string;
};
export const DownloadButton = ({
className,
fileName,
document,
disabled,
...props
}: DownloadButtonProps) => {
/**
* Convert the document from base64 to a blob and download it.
*/
const onDownloadClick = () => {
if (!document) {
return;
}
let decodedDocument = document;
try {
decodedDocument = atob(document);
} catch (err) {
// We're just going to ignore this error and try to download the document
console.error(err);
}
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
const blob = new Blob([documentBytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = fileName || 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
};
return (
<Button
type="button"
variant="outline"
className={className}
disabled={disabled || !document}
onClick={onDownloadClick}
{...props}
>
<Download className="mr-2 h-5 w-5" />
Download
</Button>
);
};

View File

@ -0,0 +1,107 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { CheckCircle2, Clock8, Share } from 'lucide-react';
import { match } from 'ts-pattern';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { DownloadButton } from './download-button';
import { SigningCard } from './signing-card';
export type CompletedSigningPageProps = {
params: {
token?: string;
};
};
export default async function CompletedSigningPage({
params: { token },
}: CompletedSigningPageProps) {
if (!token) {
return notFound();
}
const document = await getDocumentAndSenderByToken({
token,
}).catch(() => null);
if (!document) {
return notFound();
}
const [fields, recipient] = await Promise.all([
getFieldsForToken({ token }),
getRecipientByToken({ token }),
]);
const recipientName =
recipient.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
recipient.email;
return (
<div className="flex flex-col items-center pt-24">
{/* Card with recipient */}
<SigningCard name={recipientName} />
<div className="mt-6">
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
<div className="text-documenso-700 flex items-center text-center">
<CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">Everyone has signed</span>
</div>
))
.otherwise(() => (
<div className="flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span>
</div>
))}
</div>
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
You have signed "{document.title}"
</h2>
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
Everyone has signed! You will receive an Email copy of the signed document.
</p>
))
.otherwise(() => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
You will receive an Email copy of the signed document once everyone has signed.
</p>
))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
{/* TODO: Hook this up */}
<Button variant="outline" className="flex-1">
<Share className="mr-2 h-5 w-5" />
Share
</Button>
<DownloadButton
className="flex-1"
fileName={document.title}
document={document.status === DocumentStatus.COMPLETED ? document.document : undefined}
disabled={document.status !== DocumentStatus.COMPLETED}
/>
</div>
<p className="text-muted-foreground/60 mt-36 text-sm">
Want so send slick signing links like this one?{' '}
<Link href="https://documenso.com" className="text-documenso-700 hover:text-documenso-600">
Check out Documenso.
</Link>
</p>
</div>
);
}

View File

@ -0,0 +1,67 @@
'use client';
import Image from 'next/image';
import { motion } from 'framer-motion';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import signingCelebration from '~/assets/signing-celebration.png';
export type SigningCardProps = {
name: string;
};
export const SigningCard = ({ name }: SigningCardProps) => {
return (
<div className="relative w-full max-w-xs md:max-w-sm">
<Card
className="group mx-auto flex aspect-[21/9] w-full items-center justify-center"
degrees={-145}
gradient
>
<CardContent
className="font-signature p-6 text-center"
style={{
container: 'main',
}}
>
<span
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
style={{
fontSize: `max(min(4rem, ${(100 / name.length / 2).toFixed(4)}cqw), 1.875rem)`,
}}
>
{name}
</span>
</CardContent>
</Card>
<motion.div
className="absolute -inset-32 -z-10 flex items-center justify-center md:-inset-44 xl:-inset-60 2xl:-inset-80"
initial={{
opacity: 0,
scale: 0.8,
}}
animate={{
scale: 1,
opacity: 0.5,
}}
transition={{
delay: 0.5,
duration: 0.5,
}}
>
<Image
src={signingCelebration}
alt="background pattern"
className="w-full"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
}}
/>
</motion.div>
</div>
);
};

View File

@ -0,0 +1,94 @@
'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 { SigningFieldContainer } from './signing-field-container';
export type DateFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
};
export const DateField = ({ field, recipient }: DateFieldProps) => {
const router = useRouter();
const { toast } = useToast();
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: '',
});
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">Date</p>
)}
{field.inserted && (
<p className="text-muted-foreground text-sm duration-200">{field.customText}</p>
)}
</SigningFieldContainer>
);
};

View File

@ -0,0 +1,126 @@
'use client';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { Document, Field, Recipient } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useRequiredSigningContext } from './provider';
export type SigningFormProps = {
document: Document;
recipient: Recipient;
fields: Field[];
};
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
const router = useRouter();
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
const {
handleSubmit,
formState: { isSubmitting },
} = useForm();
const isComplete = fields.every((f) => f.inserted);
const onFormSubmit = async () => {
if (!isComplete) {
return;
}
await completeDocumentWithToken({
token: recipient.token,
documentId: document.id,
});
router.push(`/sign/${recipient.token}/complete`);
};
return (
<form
className={cn(
'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)}
>
<div className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}>
<div className={cn('flex flex-1 flex-col')}>
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
<p className="text-muted-foreground mt-2 text-sm">
Please review the document before signing.
</p>
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">Full Name</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="Signature">Signature</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
defaultValue={signature ?? undefined}
onChange={(value) => {
console.log({
signpadValue: value,
});
setSignature(value);
}}
/>
</CardContent>
</Card>
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
>
Cancel
</Button>
<Button
className="w-full"
type="submit"
size="lg"
disabled={!isComplete || isSubmitting}
>
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
Complete
</Button>
</div>
</div>
</div>
</div>
</form>
);
};

View File

@ -0,0 +1,24 @@
import React from 'react';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
import { NextAuthProvider } from '~/providers/next-auth';
export type SigningLayoutProps = {
children: React.ReactNode;
};
export default async function SigningLayout({ children }: SigningLayoutProps) {
const user = await getServerComponentSession();
return (
<NextAuthProvider>
<div className="min-h-screen overflow-hidden">
{user && <AuthenticatedHeader user={user} />}
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
</div>
</NextAuthProvider>
);
}

View File

@ -0,0 +1,163 @@
'use client';
import { useState, 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 { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } 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 { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container';
export type NameFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
};
export const NameField = ({ field, recipient }: NameFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const { fullName: providedFullName, setFullName: setProvidedFullName } =
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 [showFullNameModal, setShowFullNameModal] = useState(false);
const [localFullName, setLocalFullName] = useState('');
const onSign = async (source: 'local' | 'provider' = 'provider') => {
try {
if (!providedFullName && !localFullName) {
setShowFullNameModal(true);
return;
}
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: source === 'local' && localFullName ? localFullName : providedFullName ?? '',
isBase64: false,
});
if (source === 'local' && !providedFullName) {
setProvidedFullName(localFullName);
}
setLocalFullName('');
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">Name</p>
)}
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
<DialogContent>
<DialogTitle>
Sign as {recipient.name}{' '}
<span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle>
<div className="py-4">
<Label htmlFor="signature">Full Name</Label>
<Input
type="text"
className="mt-2"
value={localFullName}
onChange={(e) => setLocalFullName(e.target.value)}
/>
</div>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowFullNameModal(false);
setLocalFullName('');
}}
>
Cancel
</Button>
<Button
type="button"
className="flex-1"
disabled={!localFullName}
onClick={() => {
setShowFullNameModal(false);
onSign('local');
}}
>
Sign
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</SigningFieldContainer>
);
};

View File

@ -0,0 +1,93 @@
import { notFound } from 'next/navigation';
import { match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { FieldType } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DateField } from './date-field';
import { SigningForm } from './form';
import { NameField } from './name-field';
import { SigningProvider } from './provider';
import { SignatureField } from './signature-field';
export type SigningPageProps = {
params: {
token?: string;
};
};
export default async function SigningPage({ params: { token } }: SigningPageProps) {
if (!token) {
return notFound();
}
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }),
viewedDocument({ token }),
]);
if (!document) {
return notFound();
}
const documentUrl = `data:application/pdf;base64,${document.document}`;
return (
<SigningProvider email={recipient.email} fullName={recipient.name}>
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<p className="text-muted-foreground">
{document.User.name} ({document.User.email}) has invited you to sign this document.
</p>
</div>
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
<Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer document={documentUrl} />
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm document={document} recipient={recipient} fields={fields} />
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<SignatureField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.NAME, () => (
<NameField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.DATE, () => (
<DateField key={field.id} field={field} recipient={recipient} />
))
.otherwise(() => null),
)}
</ElementVisible>
</div>
</SigningProvider>
);
}

View File

@ -0,0 +1,63 @@
'use client';
import { createContext, useContext, useState } from 'react';
export type SigningContextValue = {
fullName: string;
setFullName: (_value: string) => void;
email: string;
setEmail: (_value: string) => void;
signature: string | null;
setSignature: (_value: string | null) => void;
};
const SigningContext = createContext<SigningContextValue | null>(null);
export const useSigningContext = () => {
return useContext(SigningContext);
};
export const useRequiredSigningContext = () => {
const context = useSigningContext();
if (!context) {
throw new Error('Signing context is required');
}
return context;
};
export interface SigningProviderProps {
fullName?: string;
email?: string;
signature?: string;
children: React.ReactNode;
}
export const SigningProvider = ({
fullName: initialFullName,
email: initialEmail,
signature: initialSignature,
children,
}: SigningProviderProps) => {
const [fullName, setFullName] = useState(initialFullName || '');
const [email, setEmail] = useState(initialEmail || '');
const [signature, setSignature] = useState(initialSignature || null);
return (
<SigningContext.Provider
value={{
fullName,
setFullName,
email,
setEmail,
signature,
setSignature,
}}
>
{children}
</SigningContext.Provider>
);
};
SigningProvider.displayName = 'SigningProvider';

View File

@ -0,0 +1,196 @@
'use client';
import { useMemo, useState, 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 { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container';
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
export type SignatureFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
};
export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const { signature: providedSignature, setSignature: setProvidedSignature } =
useRequiredSigningContext();
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation();
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation();
const { Signature: signature } = field;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const [showSignatureModal, setShowSignatureModal] = useState(false);
const [localSignature, setLocalSignature] = useState<string | null>(null);
const state = useMemo<SignatureFieldState>(() => {
if (!field.inserted) {
return 'empty';
}
if (signature?.signatureImageAsBase64) {
return 'signed-image';
}
return 'signed-text';
}, [field.inserted, signature?.signatureImageAsBase64]);
const onSign = async (source: 'local' | 'provider' = 'provider') => {
try {
console.log({
providedSignature,
localSignature,
});
if (!providedSignature && !localSignature) {
setShowSignatureModal(true);
return;
}
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: source === 'local' && localSignature ? localSignature : providedSignature ?? '',
isBase64: true,
});
if (source === 'local' && !providedSignature) {
setProvidedSignature(localSignature);
}
setLocalSignature(null);
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>
)}
{state === 'empty' && (
<p className="group-hover:text-primary font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
Signature
</p>
)}
{state === 'signed-image' && signature?.signatureImageAsBase64 && (
<img
src={signature.signatureImageAsBase64}
alt={`Signature for ${recipient.name}`}
className="h-full w-full object-contain"
/>
)}
{state === 'signed-text' && (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{signature?.typedSignature}
</p>
)}
<Dialog open={showSignatureModal} onOpenChange={setShowSignatureModal}>
<DialogContent>
<DialogTitle>
Sign as {recipient.name}{' '}
<span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle>
<div className="">
<Label htmlFor="signature">Signature</Label>
<SignaturePad
id="signature"
className="border-border mt-2 h-44 w-full rounded-md border"
onChange={(value) => setLocalSignature(value)}
/>
</div>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowSignatureModal(false);
setLocalSignature(null);
}}
>
Cancel
</Button>
<Button
type="button"
className="flex-1"
disabled={!localSignature}
onClick={() => {
setShowSignatureModal(false);
onSign('local');
}}
>
Sign
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</SigningFieldContainer>
);
};

View File

@ -0,0 +1,81 @@
'use client';
import React from 'react';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { useFieldPageCoords } from '~/hooks/use-field-page-coords';
export type SignatureFieldProps = {
field: FieldWithSignature;
loading?: boolean;
children: React.ReactNode;
onSign?: () => Promise<void> | void;
onRemove?: () => Promise<void> | void;
};
export const SigningFieldContainer = ({
field,
loading,
onSign,
onRemove,
children,
}: SignatureFieldProps) => {
const coords = useFieldPageCoords(field);
const onSignFieldClick = async () => {
if (field.inserted) {
return;
}
await onSign?.();
};
const onRemoveSignedFieldClick = async () => {
if (!field.inserted) {
return;
}
await onRemove?.();
};
return (
<div
className="absolute"
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
height: `${coords.height}px`,
width: `${coords.width}px`,
}}
>
<Card
className="bg-background relative h-full w-full"
data-inserted={field.inserted ? 'true' : 'false'}
>
<CardContent
className={cn(
'text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2',
)}
>
{!field.inserted && !loading && (
<button type="submit" className="absolute inset-0 z-10" onClick={onSignFieldClick} />
)}
{field.inserted && !loading && (
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
)}
{children}
</CardContent>
</Card>
</div>
);
};

View File

@ -1,15 +1,22 @@
import { Inter } from 'next/font/google';
import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import { getServerComponentAllFlags } from '~/helpers/get-server-component-feature-flag';
import { FeatureFlagProvider } from '~/providers/feature-flag';
import { ThemeProvider } from '~/providers/next-theme';
import { PlausibleProvider } from '~/providers/plausible';
import { PostHogPageview } from '~/providers/posthog';
import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = {
title: 'Documenso - The Open Source DocuSign Alternative',
@ -35,9 +42,15 @@ export const metadata = {
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getServerComponentAllFlags();
return (
<html lang="en" className={fontInter.variable} suppressHydrationWarning>
<html
lang="en"
className={cn(fontInter.variable, fontCaveat.variable)}
suppressHydrationWarning
>
<head>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
@ -45,15 +58,21 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<link rel="manifest" href="/site.webmanifest" />
</head>
<Suspense>
<PostHogPageview />
</Suspense>
<body>
<PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TrpcProvider>{children}</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
</PlausibleProvider>
<Toaster />
<FeatureFlagProvider initialFlags={flags}>
<PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TrpcProvider>{children}</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
</PlausibleProvider>
<Toaster />
</FeatureFlagProvider>
</body>
</html>
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB

View File

@ -12,7 +12,7 @@ export type StackAvatarProps = {
first?: boolean;
zIndex?: string;
fallbackText?: string;
type: 'unsigned' | 'waiting' | 'completed';
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
};
export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => {
@ -28,6 +28,9 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr
case 'unsigned':
classes = 'bg-dawn-200 text-dawn-900';
break;
case 'opened':
classes = 'bg-yellow-200 text-yellow-700';
break;
case 'waiting':
classes = 'bg-water text-water-700';
break;
@ -43,7 +46,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr
className={`
${zIndexClass}
${firstClass}
h-10 w-10 border-2 border-solid border-white`}
dark:border-border h-10 w-10 border-2 border-solid border-white`}
>
<AvatarFallback className={classes}>{fallbackText ?? 'UK'}</AvatarFallback>
</Avatar>

View File

@ -11,26 +11,41 @@ import {
import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars';
export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[] }) => {
export type StackAvatarsWithTooltipProps = {
recipients: Recipient[];
position?: 'top' | 'bottom';
children?: React.ReactNode;
};
export const StackAvatarsWithTooltip = ({
recipients,
position,
children,
}: StackAvatarsWithTooltipProps) => {
const waitingRecipients = recipients.filter(
(recipient) => recipient.sendStatus === 'SENT' && recipient.signingStatus === 'NOT_SIGNED',
(recipient) => getRecipientType(recipient) === 'waiting',
);
const openedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === 'opened',
);
const completedRecipients = recipients.filter(
(recipient) => recipient.sendStatus === 'SENT' && recipient.signingStatus === 'SIGNED',
(recipient) => getRecipientType(recipient) === 'completed',
);
const uncompletedRecipients = recipients.filter(
(recipient) => recipient.sendStatus === 'NOT_SENT' && recipient.signingStatus === 'NOT_SIGNED',
(recipient) => getRecipientType(recipient) === 'unsigned',
);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="flex cursor-pointer">
<StackAvatars recipients={recipients} />
{children || <StackAvatars recipients={recipients} />}
</TooltipTrigger>
<TooltipContent>
<TooltipContent side={position}>
<div className="flex flex-col gap-y-5 p-1">
{completedRecipients.length > 0 && (
<div>
@ -66,6 +81,23 @@ export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[
</div>
)}
{openedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={initials(recipient.name)}
/>
<span className="text-sm text-gray-500">{recipient.email}</span>
</div>
))}
</div>
)}
{uncompletedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Uncompleted</h1>

View File

@ -2,30 +2,17 @@
import { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@documenso/ui/lib/utils';
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const pathname = usePathname();
// const pathname = usePathname();
return (
<div className={cn('ml-8 hidden flex-1 gap-x-6 md:flex', className)} {...props}>
<Link
href="/dashboard"
className={cn(
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
{
'text-foreground': pathname?.startsWith('/dashboard'),
},
)}
>
Dashboard
</Link>
<Link
{/* We have no other subpaths rn */}
{/* <Link
href="/documents"
className={cn(
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 ',
@ -35,14 +22,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
)}
>
Documents
</Link>
{/* <Link
href="/settings/profile"
className={cn('font-medium leading-5 text-[#A1A1AA] hover:opacity-80', {
'text-primary-foreground': pathname?.startsWith('/settings'),
})}
>
Settings
</Link> */}
</div>
);

View File

@ -4,11 +4,8 @@ import { HTMLAttributes } from 'react';
import Link from 'next/link';
import { Menu } from 'lucide-react';
import { User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Logo } from '~/components/branding/logo';
@ -23,7 +20,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
return (
<header
className={cn(
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-40 flex h-16 w-full items-center border-b backdrop-blur',
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b backdrop-blur',
className,
)}
{...props}
@ -41,9 +38,9 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
<div className="flex gap-x-4">
<ProfileDropdown user={user} />
<Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
<Menu className="h-6 w-6" />
</Button>
</Button> */}
</div>
</div>
</header>

View File

@ -15,7 +15,6 @@ import {
import { signOut } from 'next-auth/react';
import { useTheme } from 'next-themes';
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
import { User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
@ -28,11 +27,19 @@ import {
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useFeatureFlags } from '~/providers/feature-flag';
export type ProfileDropdownProps = {
user: User;
};
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
const { theme, setTheme } = useTheme();
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
const initials =
user.name
?.split(' ')
@ -40,8 +47,6 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
.slice(0, 2)
.join('') ?? 'UK';
const { theme, setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -69,7 +74,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
</Link>
</DropdownMenuItem>
{IS_SUBSCRIPTIONS_ENABLED && (
{isBillingEnabled && (
<DropdownMenuItem asChild>
<Link href="/settings/billing" className="cursor-pointer">
<CreditCard className="mr-2 h-4 w-4" />

View File

@ -39,7 +39,7 @@ export const PeriodSelector = () => {
params.delete('period');
}
router.push(`${pathname}?${params.toString()}`);
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
return (

View File

@ -0,0 +1,23 @@
'use client';
import { useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
export const RefreshOnFocus = () => {
const { refresh } = useRouter();
const onFocus = useCallback(() => {
refresh();
}, [refresh]);
useEffect(() => {
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
};
}, [onFocus]);
return null;
};

View File

@ -7,15 +7,20 @@ import { usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react';
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useFeatureFlags } from '~/providers/feature-flag';
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const pathname = usePathname();
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
<Link href="/settings/profile">
@ -44,7 +49,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
{IS_SUBSCRIPTIONS_ENABLED && (
{isBillingEnabled && (
<Link href="/settings/billing">
<Button
variant="ghost"

View File

@ -7,15 +7,20 @@ import { usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react';
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useFeatureFlags } from '~/providers/feature-flag';
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
const pathname = usePathname();
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
return (
<div
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
@ -47,7 +52,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
{IS_SUBSCRIPTIONS_ENABLED && (
{isBillingEnabled && (
<Link href="/settings/billing">
<Button
variant="ghost"

View File

@ -21,12 +21,12 @@ import {
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { claimPlan } from '~/api/claim-plan/fetcher';
import { FormErrorMessage } from '../form/form-error-message';
import { SignaturePad } from '../signature-pad';
const ZWidgetFormSchema = z
.object({

View File

@ -3,21 +3,17 @@ import { HTMLAttributes } from 'react';
import { CheckCircle2, Clock, File } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils';
type FriendlyStatus = {
label: string;
icon: LucideIcon;
icon?: LucideIcon;
color: string;
};
const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = {
DRAFT: {
label: 'Draft',
icon: File,
color: 'text-yellow-500',
},
const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
PENDING: {
label: 'Pending',
icon: Clock,
@ -28,10 +24,24 @@ const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = {
icon: CheckCircle2,
color: 'text-green-500',
},
DRAFT: {
label: 'Draft',
icon: File,
color: 'text-yellow-500',
},
INBOX: {
label: 'Inbox',
icon: SignatureIcon,
color: 'text-muted-foreground',
},
ALL: {
label: 'All',
color: 'text-muted-foreground',
},
};
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
status: InternalDocumentStatus;
status: ExtendedDocumentStatus;
inheritColor?: boolean;
};
@ -45,11 +55,13 @@ export const DocumentStatus = ({
return (
<span className={cn('flex items-center', className)} {...props}>
<Icon
className={cn('mr-2 inline-block h-4 w-4', {
[color]: !inheritColor,
})}
/>
{Icon && (
<Icon
className={cn('mr-2 inline-block h-4 w-4', {
[color]: !inheritColor,
})}
/>
)}
{label}
</span>
);

View File

@ -2,8 +2,7 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { TAddFieldsFormSchema } from './add-fields.types';
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
export type AddFieldsActionInput = TAddFieldsFormSchema & {
documentId: number;

View File

@ -2,8 +2,7 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { TAddSignersFormSchema } from './add-signers.types';
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
export type AddSignersActionInput = TAddSignersFormSchema & {
documentId: number;

View File

@ -2,8 +2,7 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { TAddSubjectFormSchema } from './add-subject.types';
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
documentId: number;

View File

@ -1,53 +0,0 @@
import React, { createContext, useRef } from 'react';
import { OnPDFViewerPageClick } from '~/components/(dashboard)/pdf-viewer/pdf-viewer';
type EditFormContextValue = {
firePageClickEvent: OnPDFViewerPageClick;
registerPageClickHandler: (_handler: OnPDFViewerPageClick) => void;
unregisterPageClickHandler: (_handler: OnPDFViewerPageClick) => void;
} | null;
const EditFormContext = createContext<EditFormContextValue>(null);
export type EditFormProviderProps = {
children: React.ReactNode;
};
export const useEditForm = () => {
const context = React.useContext(EditFormContext);
if (!context) {
throw new Error('useEditForm must be used within a EditFormProvider');
}
return context;
};
export const EditFormProvider = ({ children }: EditFormProviderProps) => {
const handlers = useRef(new Set<OnPDFViewerPageClick>());
const firePageClickEvent: OnPDFViewerPageClick = (event) => {
handlers.current.forEach((handler) => handler(event));
};
const registerPageClickHandler = (handler: OnPDFViewerPageClick) => {
handlers.current.add(handler);
};
const unregisterPageClickHandler = (handler: OnPDFViewerPageClick) => {
handlers.current.delete(handler);
};
return (
<EditFormContext.Provider
value={{
firePageClickEvent,
registerPageClickHandler,
unregisterPageClickHandler,
}}
>
{children}
</EditFormContext.Provider>
);
};

View File

@ -18,8 +18,8 @@ import { FormErrorMessage } from '../form/form-error-message';
export const ZPasswordFormSchema = z
.object({
password: z.string().min(6),
repeatedPassword: z.string().min(6),
password: z.string().min(6).max(72),
repeatedPassword: z.string().min(6).max(72),
})
.refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',
@ -92,6 +92,9 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
<Input
id="password"
type="password"
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2"
{...register('password')}
/>
@ -107,6 +110,9 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
<Input
id="repeated-password"
type="password"
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2"
{...register('repeatedPassword')}
/>

View File

@ -1,5 +1,7 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
@ -12,10 +14,10 @@ import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { FormErrorMessage } from '../form/form-error-message';
import { SignaturePad } from '../signature-pad';
export const ZProfileFormSchema = z.object({
name: z.string().min(1),
@ -30,6 +32,8 @@ export type ProfileFormProps = {
};
export const ProfileForm = ({ className, user }: ProfileFormProps) => {
const router = useRouter();
const { toast } = useToast();
const {
@ -59,6 +63,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
description: 'Your profile has been updated successfully.',
duration: 5000,
});
router.refresh();
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({

View File

@ -15,7 +15,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZSignInFormSchema = z.object({
email: z.string().email().min(1),
password: z.string().min(1),
password: z.string().min(6).max(72),
});
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
@ -44,7 +44,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
await signIn('credentials', {
email,
password,
callbackUrl: '/dashboard',
callbackUrl: '/documents',
}).catch((err) => {
console.error(err);
});
@ -99,6 +99,9 @@ export const SignInForm = ({ className }: SignInFormProps) => {
<Input
id="password"
type="password"
minLength={6}
maxLength={72}
autoComplete="current-password"
className="bg-background mt-2"
{...register('password')}
/>

View File

@ -12,14 +12,13 @@ import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SignaturePad } from '../signature-pad';
export const ZSignUpFormSchema = z.object({
name: z.string().min(1),
email: z.string().email().min(1),
password: z.string().min(1),
password: z.string().min(6).max(72),
});
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
@ -106,6 +105,9 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
<Input
id="password"
type="password"
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2"
{...register('password')}
/>

View File

@ -0,0 +1,7 @@
'use client';
import { motion } from 'framer-motion';
export * from 'framer-motion';
export const MotionDiv = motion.div;

View File

@ -1,321 +0,0 @@
import { Point } from './point';
export class Canvas {
private readonly $canvas: HTMLCanvasElement;
private readonly $offscreenCanvas: HTMLCanvasElement;
private currentCanvasWidth = 0;
private currentCanvasHeight = 0;
private points: Point[] = [];
private onChangeHandlers: Array<(_canvas: Canvas, _cleared: boolean) => void> = [];
private isPressed = false;
private lastVelocity = 0;
private readonly VELOCITY_FILTER_WEIGHT = 0.5;
private readonly DPI = 2;
constructor(canvas: HTMLCanvasElement) {
this.$canvas = canvas;
this.$offscreenCanvas = document.createElement('canvas');
const { width, height } = this.$canvas.getBoundingClientRect();
this.currentCanvasWidth = width * this.DPI;
this.currentCanvasHeight = height * this.DPI;
this.$canvas.width = this.currentCanvasWidth;
this.$canvas.height = this.currentCanvasHeight;
Object.assign(this.$canvas.style, {
touchAction: 'none',
msTouchAction: 'none',
userSelect: 'none',
});
window.addEventListener('resize', this.onResize.bind(this));
this.$canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
this.$canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
this.$canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
this.$canvas.addEventListener('mouseenter', this.onMouseEnter.bind(this));
this.$canvas.addEventListener('mouseleave', this.onMouseLeave.bind(this));
this.$canvas.addEventListener('pointerdown', this.onMouseDown.bind(this));
this.$canvas.addEventListener('pointermove', this.onMouseMove.bind(this));
this.$canvas.addEventListener('pointerup', this.onMouseUp.bind(this));
}
/**
* Calculates the minimum stroke width as a percentage of the current canvas suitable for a signature.
*/
private minStrokeWidth(): number {
return Math.min(this.currentCanvasWidth, this.currentCanvasHeight) * 0.005;
}
/**
* Calculates the maximum stroke width as a percentage of the current canvas suitable for a signature.
*/
private maxStrokeWidth(): number {
return Math.min(this.currentCanvasWidth, this.currentCanvasHeight) * 0.035;
}
/**
* Retrieves the HTML canvas element.
*/
public getCanvas(): HTMLCanvasElement {
return this.$canvas;
}
/**
* Retrieves the 2D rendering context of the canvas.
* Throws an error if the context is not available.
*/
public getContext(): CanvasRenderingContext2D {
const ctx = this.$canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas context is not available.');
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
return ctx;
}
/**
* Handles the resize event of the canvas.
* Adjusts the canvas size and preserves the content using image data.
*/
private onResize(): void {
const { width, height } = this.$canvas.getBoundingClientRect();
const oldWidth = this.currentCanvasWidth;
const oldHeight = this.currentCanvasHeight;
const ctx = this.getContext();
const imageData = ctx.getImageData(0, 0, oldWidth, oldHeight);
this.$canvas.width = width * this.DPI;
this.$canvas.height = height * this.DPI;
this.currentCanvasWidth = width * this.DPI;
this.currentCanvasHeight = height * this.DPI;
ctx.putImageData(imageData, 0, 0, 0, 0, width * this.DPI, height * this.DPI);
}
/**
* Handles the mouse down event on the canvas.
* Adds the starting point for the signature.
*/
private onMouseDown(event: MouseEvent | PointerEvent | TouchEvent): void {
if (event.cancelable) {
event.preventDefault();
}
this.isPressed = true;
const point = Point.fromEvent(event, this.DPI);
this.addPoint(point);
}
/**
* Handles the mouse move event on the canvas.
* Adds a point to the signature if the mouse is pressed, based on the sample rate.
*/
private onMouseMove(event: MouseEvent | PointerEvent | TouchEvent): void {
if (event.cancelable) {
event.preventDefault();
}
if (!this.isPressed) {
return;
}
const point = Point.fromEvent(event, this.DPI);
if (point.distanceTo(this.points[this.points.length - 1]) > 10) {
this.addPoint(point);
}
}
/**
* Handles the mouse up event on the canvas.
* Adds the final point for the signature and resets the points array.
*/
private onMouseUp(event: MouseEvent | PointerEvent | TouchEvent, addPoint = true): void {
if (event.cancelable) {
event.preventDefault();
}
this.isPressed = false;
const point = Point.fromEvent(event, this.DPI);
if (addPoint) {
this.addPoint(point);
}
this.onChangeHandlers.forEach((handler) => handler(this, false));
this.points = [];
}
private onMouseEnter(event: MouseEvent): void {
if (event.cancelable) {
event.preventDefault();
}
event.buttons === 1 && this.onMouseDown(event);
}
private onMouseLeave(event: MouseEvent): void {
if (event.cancelable) {
event.preventDefault();
}
this.onMouseUp(event, false);
}
/**
* Adds a point to the signature and performs smoothing and drawing.
*/
private addPoint(point: Point): void {
const lastPoint = this.points[this.points.length - 1] ?? point;
this.points.push(point);
const smoothedPoints = this.smoothSignature(this.points);
let velocity = point.velocityFrom(lastPoint);
velocity =
this.VELOCITY_FILTER_WEIGHT * velocity +
(1 - this.VELOCITY_FILTER_WEIGHT) * this.lastVelocity;
const newWidth =
velocity > 0 && this.lastVelocity > 0 ? this.strokeWidth(velocity) : this.minStrokeWidth();
this.drawSmoothSignature(smoothedPoints, newWidth);
this.lastVelocity = velocity;
}
/**
* Applies a smoothing algorithm to the signature points.
*/
private smoothSignature(points: Point[]): Point[] {
const smoothedPoints: Point[] = [];
const startPoint = points[0];
const endPoint = points[points.length - 1];
smoothedPoints.push(startPoint);
for (let i = 0; i < points.length - 1; i++) {
const p0 = i > 0 ? points[i - 1] : startPoint;
const p1 = points[i];
const p2 = points[i + 1];
const p3 = i < points.length - 2 ? points[i + 2] : endPoint;
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
smoothedPoints.push(new Point(cp1x, cp1y));
smoothedPoints.push(new Point(cp2x, cp2y));
smoothedPoints.push(p2);
}
return smoothedPoints;
}
/**
* Draws the smoothed signature on the canvas.
*/
private drawSmoothSignature(points: Point[], width: number): void {
const ctx = this.getContext();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
const startPoint = points[0];
ctx.moveTo(startPoint.x, startPoint.y);
ctx.lineWidth = width;
for (let i = 1; i < points.length; i += 3) {
const cp1 = points[i];
const cp2 = points[i + 1];
const endPoint = points[i + 2];
ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, endPoint.x, endPoint.y);
}
ctx.stroke();
ctx.closePath();
}
/**
* Calculates the stroke width based on the velocity.
*/
private strokeWidth(velocity: number): number {
return Math.max(this.maxStrokeWidth() / (velocity + 1), this.minStrokeWidth());
}
public registerOnChangeHandler(handler: (_canvas: Canvas, _cleared: boolean) => void): void {
this.onChangeHandlers.push(handler);
}
public unregisterOnChangeHandler(handler: (_canvas: Canvas, _cleared: boolean) => void): void {
this.onChangeHandlers = this.onChangeHandlers.filter((l) => l !== handler);
}
/**
* Retrieves the signature as a data URL.
*/
public toDataURL(type?: string, quality?: number): string {
return this.$canvas.toDataURL(type, quality);
}
/**
* Clears the signature from the canvas.
*/
public clear(): void {
const ctx = this.getContext();
ctx.clearRect(0, 0, this.currentCanvasWidth, this.currentCanvasHeight);
this.onChangeHandlers.forEach((handler) => handler(this, true));
this.points = [];
}
/**
* Retrieves the signature as an image blob.
*/
public toBlob(type?: string, quality?: number): Promise<Blob> {
return new Promise((resolve, reject) => {
this.$canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Could not convert canvas to blob.'));
return;
}
resolve(blob);
},
type,
quality,
);
});
}
}

View File

@ -1,29 +0,0 @@
export const average = (a: number, b: number) => (a + b) / 2;
export const getSvgPathFromStroke = (points: number[][], closed = true) => {
const len = points.length;
if (len < 4) {
return ``;
}
let a = points[0];
let b = points[1];
const c = points[2];
let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(2)},${b[1].toFixed(
2,
)} ${average(b[0], c[0]).toFixed(2)},${average(b[1], c[1]).toFixed(2)} T`;
for (let i = 2, max = len - 1; i < max; i++) {
a = points[i];
b = points[i + 1];
result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(2)} `;
}
if (closed) {
result += 'Z';
}
return result;
};

View File

@ -1 +0,0 @@
export * from './signature-pad';

View File

@ -1,98 +0,0 @@
import {
MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
TouchEvent as ReactTouchEvent,
} from 'react';
export type PointLike = {
x: number;
y: number;
timestamp: number;
};
const isTouchEvent = (
event:
| ReactMouseEvent
| ReactPointerEvent
| ReactTouchEvent
| MouseEvent
| PointerEvent
| TouchEvent,
): event is TouchEvent | ReactTouchEvent => {
return 'touches' in event;
};
export class Point implements PointLike {
public x: number;
public y: number;
public timestamp: number;
constructor(x: number, y: number, timestamp?: number) {
this.x = x;
this.y = y;
this.timestamp = timestamp ?? Date.now();
}
public distanceTo(point: PointLike): number {
return Math.sqrt(Math.pow(point.x - this.x, 2) + Math.pow(point.y - this.y, 2));
}
public equals(point: PointLike): boolean {
return this.x === point.x && this.y === point.y && this.timestamp === point.timestamp;
}
public velocityFrom(start: PointLike): number {
const timeDifference = this.timestamp - start.timestamp;
if (timeDifference !== 0) {
return this.distanceTo(start) / timeDifference;
}
return 0;
}
public static fromPointLike({ x, y, timestamp }: PointLike): Point {
return new Point(x, y, timestamp);
}
public static fromEvent(
event:
| ReactMouseEvent
| ReactPointerEvent
| ReactTouchEvent
| MouseEvent
| PointerEvent
| TouchEvent,
dpi = 1,
el?: HTMLElement | null,
): Point {
const target = el ?? event.target;
if (!(target instanceof HTMLElement)) {
throw new Error('Event target is not an HTMLElement.');
}
const { top, bottom, left, right } = target.getBoundingClientRect();
let clientX, clientY;
if (isTouchEvent(event)) {
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
} else {
clientX = event.clientX;
clientY = event.clientY;
}
// create a new point snapping to the edge of the current target element if it exceeds
// the bounding box of the target element
let x = Math.min(Math.max(left, clientX), right) - left;
let y = Math.min(Math.max(top, clientY), bottom) - top;
// adjust for DPI
x *= dpi;
y *= dpi;
return new Point(x, y);
}
}

View File

@ -0,0 +1,79 @@
import { z } from 'zod';
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
import { TFeatureFlagValue, ZFeatureFlagValueSchema } from '~/providers/feature-flag';
/**
* Evaluate whether a flag is enabled for the current user.
*
* @param flag The flag to evaluate.
* @param options See `GetFlagOptions`.
* @returns Whether the flag is enabled, or the variant value of the flag.
*/
export const getFlag = async (
flag: string,
options?: GetFlagOptions,
): Promise<TFeatureFlagValue> => {
const requestHeaders = options?.requestHeaders ?? {};
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
const url = new URL(`${process.env.NEXT_PUBLIC_SITE_URL}/api/feature-flag/get`);
url.searchParams.set('flag', flag);
const response = await fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then((res) => res.json())
.then((res) => ZFeatureFlagValueSchema.parse(res))
.catch(() => false);
return response;
};
/**
* Get all feature flags for the current user if possible.
*
* @param options See `GetFlagOptions`.
* @returns A record of flags and their values for the user derived from the headers.
*/
export const getAllFlags = async (
options?: GetFlagOptions,
): Promise<Record<string, TFeatureFlagValue>> => {
const requestHeaders = options?.requestHeaders ?? {};
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS;
}
const url = new URL(`${process.env.NEXT_PUBLIC_SITE_URL}/api/feature-flag/all`);
return fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then((res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS);
};
interface GetFlagOptions {
/**
* The headers to attach to the request to evaluate flags.
*
* The authenticated user will be derived from the headers if possible.
*/
requestHeaders: Record<string, string>;
}

View File

@ -0,0 +1,16 @@
import { PostHog } from 'posthog-node';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export default function PostHogServerClient() {
const postHogConfig = extractPostHogConfig();
if (!postHogConfig) {
return null;
}
return new PostHog(postHogConfig.key, {
host: postHogConfig.host,
fetch: (...args) => fetch(...args),
});
}

View File

@ -0,0 +1,26 @@
import { headers } from 'next/headers';
import { getAllFlags, getFlag } from './get-feature-flag';
/**
* Evaluate whether a flag is enabled for the current user in a server component.
*
* @param flag The flag to evaluate.
* @returns Whether the flag is enabled, or the variant value of the flag.
*/
export const getServerComponentFlag = async (flag: string) => {
return await getFlag(flag, {
requestHeaders: Object.fromEntries(headers().entries()),
});
};
/**
* Get all feature flags for the current user from a server component.
*
* @returns A record of flags and their values for the user derived from the headers.
*/
export const getServerComponentAllFlags = async () => {
return await getAllFlags({
requestHeaders: Object.fromEntries(headers().entries()),
});
};

View File

@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
export function useDebouncedValue<T>(value: T, delay: number) {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@ -0,0 +1,78 @@
import { useCallback, useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { Field } from '@documenso/prisma/client';
export const useFieldPageCoords = (field: Field) => {
const [coords, setCoords] = useState({
x: 0,
y: 0,
height: 0,
width: 0,
});
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const { top, left, height, width } = getBoundingClientRect($page);
// X and Y are percentages of the page's height and width
const fieldX = (Number(field.positionX) / 100) * width + left;
const fieldY = (Number(field.positionY) / 100) * height + top;
const fieldHeight = (Number(field.height) / 100) * height;
const fieldWidth = (Number(field.width) / 100) * width;
setCoords({
x: fieldX,
y: fieldY,
height: fieldHeight,
width: fieldWidth,
});
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
useEffect(() => {
calculateCoords();
}, [calculateCoords]);
useEffect(() => {
const onResize = () => {
calculateCoords();
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateCoords]);
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const observer = new ResizeObserver(() => {
calculateCoords();
});
observer.observe($page);
return () => {
observer.disconnect();
};
}, [calculateCoords, field.page]);
return coords;
};

View File

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
export default async function middleware(req: NextRequest) {
if (req.nextUrl.pathname === '/') {
const redirectUrl = new URL('/dashboard', req.url);
const redirectUrl = new URL('/documents', req.url);
return NextResponse.redirect(redirectUrl);
}

Some files were not shown because too many files have changed in this diff Show More