Compare commits
188 Commits
feat/pie-c
...
feat/marke
| Author | SHA1 | Date | |
|---|---|---|---|
| 84a2d3baf6 | |||
| 74180defd1 | |||
| aeeaaf0d8d | |||
| 2b84293c4e | |||
| b38ef6c0a7 | |||
| 17af4d25bd | |||
| 6e095921e6 | |||
| 150c42b246 | |||
| aecf2f32b9 | |||
| b23967d777 | |||
| b3291c65bc | |||
| 4b849e286c | |||
| 7bcc26a987 | |||
| 692722d32e | |||
| e4f06d8e30 | |||
| c799380787 | |||
| 5540fcf0d2 | |||
| d9da09c1e7 | |||
| fe90aa3b7b | |||
| 0c680e0111 | |||
| 7bcf5fbd86 | |||
| 7218b950fe | |||
| 901013fdc6 | |||
| 5c9017f3cd | |||
| 34e962cc48 | |||
| bf9254597a | |||
| b5efa0d3ea | |||
| a2bdb46076 | |||
| ed150d9574 | |||
| e756a21fda | |||
| 13084049da | |||
| 055e723777 | |||
| 419318c151 | |||
| 7722e63e1b | |||
| 8529ac3ffe | |||
| 7ec8e762b0 | |||
| 2acada6dc7 | |||
| d4d76dce03 | |||
| 3832ce2c80 | |||
| fd36e39a38 | |||
| 14fd0eb906 | |||
| af6c62d0bf | |||
| 8d7d6a19e7 | |||
| 463dc48ea6 | |||
| d8f6a25059 | |||
| 93962625ed | |||
| 249211bd4f | |||
| bfe0d50661 | |||
| 5d4a07bcc5 | |||
| d28bb5de99 | |||
| 83a83164d4 | |||
| d71e43c5d6 | |||
| ed6fa4dc2a | |||
| 4f3970c361 | |||
| 40767430d9 | |||
| 1edfe9548d | |||
| fead48c2f0 | |||
| 0abd3da7fd | |||
| 2f78922421 | |||
| 3df0f61947 | |||
| 8e42dcb7ee | |||
| 1888ee97e6 | |||
| 068aef665d | |||
| 2772fc1678 | |||
| 8c4120f0a2 | |||
| 9f93af6134 | |||
| 3440c47c3c | |||
| 68a5a9da1e | |||
| 1f8d5e45e1 | |||
| 8fd9730e2b | |||
| 04f6df6839 | |||
| ca40e983e3 | |||
| 9257454a96 | |||
| ba054ae915 | |||
| 1d1c6e5a55 | |||
| c161a8109b | |||
| e340c4ed6f | |||
| b5f96ee2fc | |||
| 3c1790ba83 | |||
| f41c78e8e3 | |||
| b8b8b4dbad | |||
| d195dc1a46 | |||
| 3ac29d8da3 | |||
| 2418612507 | |||
| e8336ae9b4 | |||
| aad52a3e2e | |||
| 829122c486 | |||
| 090752c539 | |||
| fad6414995 | |||
| c817c67a1c | |||
| c7001e62f3 | |||
| bf71d2a14e | |||
| 163911255e | |||
| 24e38a3bbc | |||
| dfd714f16a | |||
| 722081f89e | |||
| f0e1df22b8 | |||
| 615cb263fb | |||
| 18faaf49d9 | |||
| 650b69ae56 | |||
| eb4be963e3 | |||
| 27c27743e3 | |||
| 92930a2f63 | |||
| 7ad3365b0e | |||
| f8bf4fea36 | |||
| 10cd8144eb | |||
| 66973a3745 | |||
| 85677bb792 | |||
| 7ae99d2038 | |||
| 70a5105783 | |||
| 420372ac9e | |||
| 6b00282a87 | |||
| dae1001cbb | |||
| af81d99b2a | |||
| 2751adc463 | |||
| 396ce9f3f3 | |||
| 3f4f66d878 | |||
| d6751d7a26 | |||
| 0e32baff0b | |||
| f76bf4c2c7 | |||
| 0d8532ab6d | |||
| 490d3d51e1 | |||
| 2ab796910e | |||
| 07102588be | |||
| 04f9422f24 | |||
| 05a7f5e178 | |||
| 7bae814f96 | |||
| ea45e38fa0 | |||
| 6d9a85112f | |||
| 346efd19db | |||
| 617143a47f | |||
| 66f067276e | |||
| fc10d0449f | |||
| 083f3e7108 | |||
| af307a2a49 | |||
| b063758ee5 | |||
| 4964b252e3 | |||
| e468f5bbc9 | |||
| c5b7b8a18a | |||
| a8a1fbb829 | |||
| 3c2a4892e7 | |||
| 6d9e84d327 | |||
| 73b4e30c97 | |||
| bd01545a70 | |||
| 6d360e581d | |||
| ba95818da4 | |||
| d0720f4c70 | |||
| f60cb22f11 | |||
| e0cb4314fb | |||
| 0571137a60 | |||
| 30aabf50eb | |||
| 8441a5eb98 | |||
| 259ab49bc1 | |||
| 2f2d5dfc0b | |||
| 0f27f4261b | |||
| 9b92cad2db | |||
| ad1ff6159c | |||
| f633b17f17 | |||
| 8fa16001e6 | |||
| e111234460 | |||
| 034072f50e | |||
| a7664d79fd | |||
| 8ed2393300 | |||
| 94cf150ffd | |||
| c571a3d0d9 | |||
| 1c7431b859 | |||
| 2f7d6548ef | |||
| a16525be5e | |||
| 21e377d3ff | |||
| 45d0d3f7e8 | |||
| 6e62eb8d81 | |||
| e098449af4 | |||
| 47d0030cf0 | |||
| e46607c1cb | |||
| 77c5db169a | |||
| 0157bf9576 | |||
| 454c2f45bd | |||
| badb897c06 | |||
| 9bb5768598 | |||
| 951de8baf5 | |||
| 48ceb1e5c7 | |||
| 0a30403719 | |||
| 0e1fcd86e6 | |||
| 44369ee7f6 | |||
| 7e46cb0d8e | |||
| 3b9c57fe5c | |||
| 90e28cd3a4 | |||
| e743e56787 |
@ -12,6 +12,8 @@ NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
|||||||
|
|
||||||
# [[DATABASE]]
|
# [[DATABASE]]
|
||||||
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||||
|
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
||||||
|
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||||
|
|
||||||
# [[SMTP]]
|
# [[SMTP]]
|
||||||
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
||||||
@ -52,7 +54,12 @@ NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
|||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
||||||
|
|
||||||
# [[FEATURES]]
|
# [[FEATURES]]
|
||||||
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false
|
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
||||||
|
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||||
|
# OPTIONAL: Defines the host to use for PostHog.
|
||||||
|
NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com"
|
||||||
|
# OPTIONAL: Leave blank to disable billing.
|
||||||
|
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||||
|
|
||||||
# This is only required for the marketing site
|
# This is only required for the marketing site
|
||||||
# [[REDIS]]
|
# [[REDIS]]
|
||||||
|
|||||||
7
.eslintignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Config files
|
||||||
|
*.config.js
|
||||||
|
*.config.cjs
|
||||||
|
|
||||||
|
# Statically hosted javascript files
|
||||||
|
apps/*/public/*.js
|
||||||
|
apps/*/public/*.cjs
|
||||||
32
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: 'github-actions'
|
||||||
|
directory: '/'
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
target-branch: "feat/refresh"
|
||||||
|
labels:
|
||||||
|
- "ci dependencies"
|
||||||
|
- "ci"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/apps/marketing"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
target-branch: "feat/refresh"
|
||||||
|
labels:
|
||||||
|
- "npm dependencies"
|
||||||
|
- "frontend"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/apps/web"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
target-branch: "feat/refresh"
|
||||||
|
labels:
|
||||||
|
- "npm dependencies"
|
||||||
|
- "frontend"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
33
.github/workflows/ci.yml
vendored
Normal 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
@ -0,0 +1,45 @@
|
|||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [ feat/refresh ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ feat/refresh ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: true
|
||||||
|
matrix:
|
||||||
|
language: [ 'javascript' ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build Documenso
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v2
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v2
|
||||||
21
.github/workflows/semantic-pull-requests.yml
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
name: "Validate PR Name"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- edited
|
||||||
|
- synchronize
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-pr:
|
||||||
|
name: Validate PR title
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
10
.gitignore
vendored
@ -37,3 +37,13 @@ yarn-error.log*
|
|||||||
|
|
||||||
# contentlayer
|
# contentlayer
|
||||||
.contentlayer
|
.contentlayer
|
||||||
|
|
||||||
|
# intellij
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# vscode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|||||||
4
.husky/commit-msg
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npm run commitlint -- $1
|
||||||
0
.husky/pre-commit
Normal file → Executable file
@ -89,6 +89,10 @@ Documenso is built using awesome open source tech including:
|
|||||||
- [Node SignPDF (Digital Signature)](https://github.com/vbuch/node-signpdf)
|
- [Node SignPDF (Digital Signature)](https://github.com/vbuch/node-signpdf)
|
||||||
- [React-PDF for viewing PDFs](https://github.com/wojtekmaj/react-pdf)
|
- [React-PDF for viewing PDFs](https://github.com/wojtekmaj/react-pdf)
|
||||||
- [PDF-Lib for PDF manipulation](https://github.com/Hopding/pdf-lib)
|
- [PDF-Lib for PDF manipulation](https://github.com/Hopding/pdf-lib)
|
||||||
|
- [Zod for schema declaration and validation](https://zod.dev/)
|
||||||
|
- [Lucide React for icons in React app](https://lucide.dev/)
|
||||||
|
- [Framer Motion for motion library](https://www.framer.com/motion/)
|
||||||
|
- [Radix UI for component library](https://www.radix-ui.com/)
|
||||||
- Check out `/package.json` and `/apps/web/package.json` for more
|
- Check out `/package.json` and `/apps/web/package.json` for more
|
||||||
- Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned.
|
- Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned.
|
||||||
|
|
||||||
|
|||||||
56
apps/marketing/content/blog/next.mdx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
title: Preview the next Documenso
|
||||||
|
description: We're redesigning Documenso by making it more elegant and appropriately playful. Here's a sneak peek.
|
||||||
|
authorName: 'Timur Ercan'
|
||||||
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2023-08-21
|
||||||
|
tags:
|
||||||
|
- Design
|
||||||
|
- Preview
|
||||||
|
---
|
||||||
|
|
||||||
|
Since we launched [Documenso 0.9 on Product Hunt](https://producthunt.com/products/documenso#documenso) last May, the team's been hard at work behind the scenes to ramp up development and design to deliver an excellent next version.
|
||||||
|
|
||||||
|
Last week, Lucas shared the reasoning 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.
|
||||||
36
apps/marketing/content/blog/pre-seed.mdx
Normal 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)
|
||||||
@ -4,10 +4,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=3001 next dev",
|
"dev": "next dev -p 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
@ -29,6 +30,7 @@
|
|||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.8.0",
|
||||||
"recharts": "^2.7.2",
|
"recharts": "^2.7.2",
|
||||||
|
"sharp": "0.32.5",
|
||||||
"typescript": "5.1.6",
|
"typescript": "5.1.6",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
apps/marketing/public/blog/blog-fig-preview-documenso.webp
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
apps/marketing/public/logo_icon.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
56611
apps/marketing/public/pdf.worker.min.js
vendored
Normal file
@ -5,7 +5,7 @@ import { allDocuments } from 'contentlayer/generated';
|
|||||||
import type { MDXComponents } from 'mdx/types';
|
import type { MDXComponents } from 'mdx/types';
|
||||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||||
|
|
||||||
export const generateStaticParams = async () =>
|
export const generateStaticParams = () =>
|
||||||
allDocuments.map((post) => ({ post: post._raw.flattenedPath }));
|
allDocuments.map((post) => ({ post: post._raw.flattenedPath }));
|
||||||
|
|
||||||
export const generateMetadata = ({ params }: { params: { content: string } }) => {
|
export const generateMetadata = ({ params }: { params: { content: string } }) => {
|
||||||
|
|||||||
@ -0,0 +1,76 @@
|
|||||||
|
import { ImageResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { allBlogPosts } from 'contentlayer/generated';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export const size = {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const contentType = 'image/png';
|
||||||
|
|
||||||
|
type BlogPostOpenGraphImageProps = {
|
||||||
|
params: { post: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGraphImageProps) {
|
||||||
|
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
|
||||||
|
|
||||||
|
if (!blogPost) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The long urls are needed for a compiler optimisation on the Next.js side, lifting this up
|
||||||
|
// to a constant will break og image generation.
|
||||||
|
const [interBold, interRegular, backgroundImage, logoImage] = await Promise.all([
|
||||||
|
fetch(new URL('./../../../../assets/inter-bold.ttf', import.meta.url)).then(async (res) =>
|
||||||
|
res.arrayBuffer(),
|
||||||
|
),
|
||||||
|
fetch(new URL('./../../../../assets/inter-regular.ttf', import.meta.url)).then(async (res) =>
|
||||||
|
res.arrayBuffer(),
|
||||||
|
),
|
||||||
|
fetch(new URL('./../../../../assets/background-blog-og.png', import.meta.url)).then(
|
||||||
|
async (res) => res.arrayBuffer(),
|
||||||
|
),
|
||||||
|
fetch(new URL('./../../../../../public/logo.png', import.meta.url)).then(async (res) =>
|
||||||
|
res.arrayBuffer(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div tw="relative h-full w-full flex flex-col items-center justify-center text-center">
|
||||||
|
{/* @ts-expect-error Lack of typing from ImageResponse */}
|
||||||
|
<img src={backgroundImage} alt="og-background" tw="absolute inset-0 w-full h-full" />
|
||||||
|
|
||||||
|
{/* @ts-expect-error Lack of typing from ImageResponse */}
|
||||||
|
<img src={logoImage} alt="logo" tw="h-8" />
|
||||||
|
|
||||||
|
<h1 tw="mt-8 text-6xl text-center flex items-center justify-center w-full max-w-[800px] font-bold text-center mx-auto">
|
||||||
|
{blogPost.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p tw="font-normal">Written by {blogPost.authorName}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...size,
|
||||||
|
fonts: [
|
||||||
|
{
|
||||||
|
name: 'Inter',
|
||||||
|
data: interRegular,
|
||||||
|
style: 'normal',
|
||||||
|
weight: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Inter',
|
||||||
|
data: interBold,
|
||||||
|
style: 'normal',
|
||||||
|
weight: 700,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@ import { ChevronLeft } from 'lucide-react';
|
|||||||
import type { MDXComponents } from 'mdx/types';
|
import type { MDXComponents } from 'mdx/types';
|
||||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||||
|
|
||||||
export const generateStaticParams = async () =>
|
export const generateStaticParams = () =>
|
||||||
allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
|
allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
|
||||||
|
|
||||||
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||||
@ -17,7 +17,9 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { title: `Documenso - ${blogPost.title}` };
|
return {
|
||||||
|
title: `Documenso - ${blogPost.title}`,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mdxComponents: MDXComponents = {
|
const mdxComponents: MDXComponents = {
|
||||||
|
|||||||
@ -27,7 +27,11 @@ export type ClaimedPlanPageProps = {
|
|||||||
export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlanPageProps) {
|
export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlanPageProps) {
|
||||||
const { sessionId } = searchParams;
|
const { sessionId } = searchParams;
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.retrieve(sessionId as string);
|
if (typeof sessionId !== 'string') {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@ -157,7 +161,6 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
href={`${process.env.NEXT_PUBLIC_APP_URL}/login`}
|
href={`${process.env.NEXT_PUBLIC_APP_URL}/login`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="mt-4 block"
|
className="mt-4 block"
|
||||||
|
|||||||
@ -56,11 +56,11 @@ export const FUNDING_RAISED = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2023-05',
|
date: '2023-05',
|
||||||
amount: 300_000,
|
amount: 290_000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2023-07',
|
date: '2023-07',
|
||||||
amount: 1_550_000,
|
amount: 1_540_000,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { FundingRaised } from './funding-raised';
|
|||||||
import { GithubMetric } from './gh-metrics';
|
import { GithubMetric } from './gh-metrics';
|
||||||
import { TeamMembers } from './team-members';
|
import { TeamMembers } from './team-members';
|
||||||
|
|
||||||
export const revalidate = 86400;
|
export const revalidate = 3600;
|
||||||
|
|
||||||
const ZGithubStatsResponse = z.object({
|
const ZGithubStatsResponse = z.object({
|
||||||
stargazers_count: z.number(),
|
stargazers_count: z.number(),
|
||||||
@ -43,7 +43,7 @@ export default async function OpenPage() {
|
|||||||
accept: 'application/vnd.github.v3+json',
|
accept: 'application/vnd.github.v3+json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZGithubStatsResponse.parse(res));
|
.then((res) => ZGithubStatsResponse.parse(res));
|
||||||
|
|
||||||
const { total_count: mergedPullRequests } = await fetch(
|
const { total_count: mergedPullRequests } = await fetch(
|
||||||
@ -54,7 +54,7 @@ export default async function OpenPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.then((res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZMergedPullRequestsResponse.parse(res));
|
.then((res) => ZMergedPullRequestsResponse.parse(res));
|
||||||
|
|
||||||
const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', {
|
const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', {
|
||||||
@ -62,7 +62,7 @@ export default async function OpenPage() {
|
|||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZStargazersLiveResponse.parse(res));
|
.then((res) => ZStargazersLiveResponse.parse(res));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -70,9 +70,12 @@ export default async function OpenPage() {
|
|||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
|
<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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export default async function IndexPage() {
|
|||||||
accept: 'application/vnd.github.v3+json',
|
accept: 'application/vnd.github.v3+json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => (typeof res.stargazers_count === 'number' ? res.stargazers_count : undefined))
|
.then((res) => (typeof res.stargazers_count === 'number' ? res.stargazers_count : undefined))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
|||||||
14
apps/marketing/src/app/robots.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/*',
|
||||||
|
disallow: ['/_next/*'],
|
||||||
|
},
|
||||||
|
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
41
apps/marketing/src/app/sitemap.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
import { allBlogPosts, allGenericPages } from 'contentlayer/generated';
|
||||||
|
|
||||||
|
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
const lastModified = new Date();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: baseUrl,
|
||||||
|
lastModified,
|
||||||
|
},
|
||||||
|
...allGenericPages.map((doc) => ({
|
||||||
|
url: `${baseUrl}/${doc._raw.flattenedPath}`,
|
||||||
|
lastModified,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/blog`,
|
||||||
|
lastModified,
|
||||||
|
},
|
||||||
|
...allBlogPosts.map((doc) => ({
|
||||||
|
url: `${baseUrl}/${doc._raw.flattenedPath}`,
|
||||||
|
lastModified,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/open`,
|
||||||
|
lastModified,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/oss-friends`,
|
||||||
|
lastModified,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/pricing`,
|
||||||
|
lastModified,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
BIN
apps/marketing/src/assets/background-blog-og.png
Normal file
|
After Width: | Height: | Size: 896 KiB |
BIN
apps/marketing/src/assets/inter-bold.ttf
Normal file
BIN
apps/marketing/src/assets/inter-regular.ttf
Normal file
@ -5,7 +5,7 @@ import React, { useState } from 'react';
|
|||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Info, Loader } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
import { usePlausible } from 'next-plausible';
|
import { usePlausible } from 'next-plausible';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@ -63,7 +63,9 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
|
|
||||||
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
|
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
const delay = new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
const [redirectUrl] = await Promise.all([
|
const [redirectUrl] = await Promise.all([
|
||||||
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
|
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
|
||||||
@ -85,7 +87,7 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@ -97,10 +99,8 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
className={cn('flex flex-col gap-y-4', className)}
|
<fieldset disabled={isSubmitting} className={cn('flex flex-col gap-y-4', className)}>
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
|
||||||
>
|
|
||||||
{params?.get('cancelled') === 'true' && (
|
{params?.get('cancelled') === 'true' && (
|
||||||
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
|
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
@ -133,14 +133,15 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
<FormErrorMessage className="mt-1" error={errors.email} />
|
<FormErrorMessage className="mt-1" error={errors.email} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" size="lg" disabled={isSubmitting}>
|
<Button type="submit" size="lg" loading={isSubmitting}>
|
||||||
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
|
Claim the Community Plan (
|
||||||
Claim the Community Plan ({/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
|
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
|
||||||
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
||||||
? 'Monthly'
|
? 'Monthly'
|
||||||
: 'Yearly'}
|
: 'Yearly'}
|
||||||
)
|
)
|
||||||
</Button>
|
</Button>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -3,12 +3,28 @@ import { HTMLAttributes } from 'react';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Github, Slack, Twitter } from 'lucide-react';
|
import { Github, MessagesSquare, Twitter } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
const SOCIAL_LINKS = [
|
||||||
|
{ href: 'https://twitter.com/documenso', icon: <Twitter className="h-6 w-6" /> },
|
||||||
|
{ href: 'https://github.com/documenso/documenso', icon: <Github className="h-6 w-6" /> },
|
||||||
|
{ href: 'https://documen.so/discord', icon: <MessagesSquare className="h-6 w-6" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FOOTER_LINKS = [
|
||||||
|
{ href: '/pricing', text: 'Pricing' },
|
||||||
|
{ href: '/blog', text: 'Blog' },
|
||||||
|
{ href: '/open', text: 'Open' },
|
||||||
|
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
||||||
|
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
|
||||||
|
{ href: 'mailto:support@documenso.com', text: 'Support' },
|
||||||
|
{ href: '/privacy', text: 'Privacy' },
|
||||||
|
];
|
||||||
|
|
||||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
export const Footer = ({ className, ...props }: FooterProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn('border-t py-12', className)} {...props}>
|
<div className={cn('border-t py-12', className)} {...props}>
|
||||||
@ -19,65 +35,25 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-4 text-[#8D8D8D]">
|
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-4 text-[#8D8D8D]">
|
||||||
<Link
|
{SOCIAL_LINKS.map((link, index) => (
|
||||||
href="https://twitter.com/documenso"
|
<Link key={index} href={link.href} target="_blank" className="hover:text-[#6D6D6D]">
|
||||||
target="_blank"
|
{link.icon}
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<Twitter className="h-6 w-6" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://github.com/documenso/documenso"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<Github className="h-6 w-6" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://documenso.slack.com"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<Slack className="h-6 w-6" />
|
|
||||||
</Link>
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
||||||
<Link href="/blog" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
|
{FOOTER_LINKS.map((link, index) => (
|
||||||
Blog
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
key={index}
|
||||||
|
href={link.href}
|
||||||
|
target={link.target}
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||||
>
|
>
|
||||||
Pricing
|
{link.text}
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://status.documenso.com"
|
|
||||||
target="_blank"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Status
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="mailto:support@documenso.com"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Support
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/privacy"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Privacy
|
|
||||||
</Link>
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto mt-4 w-full max-w-screen-xl px-8 md:mt-12 lg:mt-24">
|
<div className="mx-auto mt-4 w-full max-w-screen-xl px-8 md:mt-12 lg:mt-24">
|
||||||
|
|||||||
@ -1,26 +1,37 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
'use client';
|
||||||
|
|
||||||
|
import { HTMLAttributes, useState } from 'react';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
import { HamburgerMenu } from './mobile-hamburger';
|
||||||
|
import { MobileNavigation } from './mobile-navigation';
|
||||||
|
|
||||||
export type HeaderProps = HTMLAttributes<HTMLElement>;
|
export type HeaderProps = HTMLAttributes<HTMLElement>;
|
||||||
|
|
||||||
export const Header = ({ className, ...props }: HeaderProps) => {
|
export const Header = ({ className, ...props }: HeaderProps) => {
|
||||||
|
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={cn('flex items-center justify-between', className)} {...props}>
|
<header className={cn('flex items-center justify-between', className)} {...props}>
|
||||||
<Link href="/">
|
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
||||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={0}></Image>
|
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-x-6">
|
|
||||||
<Link href="/blog" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
<Link href="/blog" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
||||||
Blog
|
Blog
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
<Link href="/open" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
||||||
Pricing
|
Open
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
@ -31,6 +42,15 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
|||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<HamburgerMenu
|
||||||
|
onToggleMenuOpen={() => setIsHamburgerMenuOpen((v) => !v)}
|
||||||
|
isMenuOpen={isHamburgerMenuOpen}
|
||||||
|
/>
|
||||||
|
<MobileNavigation
|
||||||
|
isMenuOpen={isHamburgerMenuOpen}
|
||||||
|
onMenuOpenChange={setIsHamburgerMenuOpen}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Menu, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export interface HamburgerMenuProps {
|
||||||
|
isMenuOpen: boolean;
|
||||||
|
onToggleMenuOpen?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HamburgerMenu = ({ isMenuOpen, onToggleMenuOpen }: HamburgerMenuProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex md:hidden">
|
||||||
|
<Button variant="outline" className="z-20 w-10 p-0" onClick={onToggleMenuOpen}>
|
||||||
|
{isMenuOpen ? <X /> : <Menu />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
126
apps/marketing/src/components/(marketing)/mobile-navigation.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { motion, useReducedMotion } from 'framer-motion';
|
||||||
|
import { Github, MessagesSquare, Twitter } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||||
|
|
||||||
|
export type MobileNavigationProps = {
|
||||||
|
isMenuOpen: boolean;
|
||||||
|
onMenuOpenChange?: (_value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MENU_NAVIGATION_LINKS = [
|
||||||
|
{
|
||||||
|
href: '/blog',
|
||||||
|
text: 'Blog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/pricing',
|
||||||
|
text: 'Pricing',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/open',
|
||||||
|
text: 'Open',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://status.documenso.com',
|
||||||
|
text: 'Status',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'mailto:support@documenso.com',
|
||||||
|
text: 'Support',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/privacy',
|
||||||
|
text: 'Privacy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://app.documenso.com/login',
|
||||||
|
text: 'Sign in',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
const handleMenuItemClick = () => {
|
||||||
|
onMenuOpenChange?.(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||||
|
<SheetContent className="w-full max-w-[400px]">
|
||||||
|
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
|
||||||
|
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="mt-12 flex w-full flex-col items-start gap-y-4"
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
transition={{
|
||||||
|
staggerChildren: 0.03,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
|
||||||
|
<motion.div
|
||||||
|
key={href}
|
||||||
|
variants={{
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
x: shouldReduceMotion ? 0 : 100,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
ease: 'backInOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className="text-2xl font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||||
|
href={href}
|
||||||
|
onClick={() => handleMenuItemClick()}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="mx-auto mt-8 flex w-full flex-wrap items-center gap-x-4 gap-y-4 ">
|
||||||
|
<Link
|
||||||
|
href="https://twitter.com/documenso"
|
||||||
|
target="_blank"
|
||||||
|
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||||
|
>
|
||||||
|
<Twitter className="h-6 w-6" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="https://github.com/documenso/documenso"
|
||||||
|
target="_blank"
|
||||||
|
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||||
|
>
|
||||||
|
<Github className="h-6 w-6" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="https://documen.so/discord"
|
||||||
|
target="_blank"
|
||||||
|
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||||
|
>
|
||||||
|
<MessagesSquare className="h-6 w-6" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -13,7 +13,7 @@ export const PasswordReveal = ({ password }: PasswordRevealProps) => {
|
|||||||
const [, copy] = useCopyToClipboard();
|
const [, copy] = useCopyToClipboard();
|
||||||
|
|
||||||
const onCopyClick = () => {
|
const onCopyClick = () => {
|
||||||
copy(password).then(() => {
|
void copy(password).then(() => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Copied to clipboard',
|
title: 'Copied to clipboard',
|
||||||
description: 'Your password has been copied to your clipboard.',
|
description: 'Your password has been copied to your clipboard.',
|
||||||
|
|||||||
@ -22,7 +22,6 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
const event = usePlausible();
|
const event = usePlausible();
|
||||||
|
|
||||||
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() =>
|
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() =>
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
|
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
|
||||||
? 'YEARLY'
|
? 'YEARLY'
|
||||||
: 'MONTHLY',
|
: 'MONTHLY',
|
||||||
@ -30,11 +29,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
|
|
||||||
const planId = useMemo(() => {
|
const planId = useMemo(() => {
|
||||||
if (period === 'MONTHLY') {
|
if (period === 'MONTHLY') {
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID;
|
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID;
|
||||||
}, [period]);
|
}, [period]);
|
||||||
|
|
||||||
|
|||||||
@ -21,12 +21,12 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
import { claimPlan } from '~/api/claim-plan/fetcher';
|
||||||
|
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
import { FormErrorMessage } from '../form/form-error-message';
|
||||||
import { SignaturePad } from '../signature-pad';
|
|
||||||
|
|
||||||
const ZWidgetFormSchema = z
|
const ZWidgetFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
@ -124,7 +124,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
setValue('signatureDataUrl', draftSignatureDataUrl);
|
setValue('signatureDataUrl', draftSignatureDataUrl);
|
||||||
setValue('signatureText', '');
|
setValue('signatureText', '');
|
||||||
|
|
||||||
trigger('signatureDataUrl');
|
void trigger('signatureDataUrl');
|
||||||
setShowSigningDialog(false);
|
setShowSigningDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -135,9 +135,10 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
signatureText,
|
signatureText,
|
||||||
}: TWidgetFormSchema) => {
|
}: TWidgetFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
const delay = new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
||||||
|
|
||||||
const claimPlanInput = signatureDataUrl
|
const claimPlanInput = signatureDataUrl
|
||||||
@ -145,7 +146,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
planId,
|
planId,
|
||||||
signatureDataUrl: signatureDataUrl!,
|
signatureDataUrl: signatureDataUrl,
|
||||||
signatureText: null,
|
signatureText: null,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@ -153,7 +154,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
email,
|
email,
|
||||||
planId,
|
planId,
|
||||||
signatureDataUrl: null,
|
signatureDataUrl: null,
|
||||||
signatureText: signatureText!,
|
signatureText: signatureText ?? '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const [result] = await Promise.all([claimPlan(claimPlanInput), delay]);
|
const [result] = await Promise.all([claimPlan(claimPlanInput), delay]);
|
||||||
|
|||||||
@ -1,212 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
HTMLAttributes,
|
|
||||||
MouseEvent,
|
|
||||||
PointerEvent,
|
|
||||||
TouchEvent,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import { StrokeOptions, getStroke } from 'perfect-freehand';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
import { getSvgPathFromStroke } from './helper';
|
|
||||||
import { Point } from './point';
|
|
||||||
|
|
||||||
const DPI = 2;
|
|
||||||
|
|
||||||
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
|
|
||||||
onChange?: (_signatureDataUrl: string | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProps) => {
|
|
||||||
const $el = useRef<HTMLCanvasElement>(null);
|
|
||||||
|
|
||||||
const [isPressed, setIsPressed] = useState(false);
|
|
||||||
const [points, setPoints] = useState<Point[]>([]);
|
|
||||||
|
|
||||||
const perfectFreehandOptions = useMemo(() => {
|
|
||||||
const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10;
|
|
||||||
|
|
||||||
return {
|
|
||||||
size,
|
|
||||||
thinning: 0.25,
|
|
||||||
streamline: 0.5,
|
|
||||||
smoothing: 0.5,
|
|
||||||
end: {
|
|
||||||
taper: size * 2,
|
|
||||||
},
|
|
||||||
} satisfies StrokeOptions;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onMouseDown = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
|
||||||
if (event.cancelable) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsPressed(true);
|
|
||||||
|
|
||||||
const point = Point.fromEvent(event, DPI, $el.current);
|
|
||||||
|
|
||||||
const newPoints = [...points, point];
|
|
||||||
|
|
||||||
setPoints(newPoints);
|
|
||||||
|
|
||||||
if ($el.current) {
|
|
||||||
const ctx = $el.current.getContext('2d');
|
|
||||||
|
|
||||||
if (ctx) {
|
|
||||||
ctx.save();
|
|
||||||
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
|
||||||
ctx.imageSmoothingQuality = 'high';
|
|
||||||
|
|
||||||
const pathData = new Path2D(
|
|
||||||
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.fill(pathData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
|
||||||
if (event.cancelable) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPressed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const point = Point.fromEvent(event, DPI, $el.current);
|
|
||||||
|
|
||||||
if (point.distanceTo(points[points.length - 1]) > 5) {
|
|
||||||
const newPoints = [...points, point];
|
|
||||||
|
|
||||||
setPoints(newPoints);
|
|
||||||
|
|
||||||
if ($el.current) {
|
|
||||||
const ctx = $el.current.getContext('2d');
|
|
||||||
|
|
||||||
if (ctx) {
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
|
||||||
ctx.imageSmoothingQuality = 'high';
|
|
||||||
|
|
||||||
const pathData = new Path2D(
|
|
||||||
getSvgPathFromStroke(getStroke(points, perfectFreehandOptions)),
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.fill(pathData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addPoint = true) => {
|
|
||||||
if (event.cancelable) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsPressed(false);
|
|
||||||
|
|
||||||
const point = Point.fromEvent(event, DPI, $el.current);
|
|
||||||
|
|
||||||
const newPoints = [...points];
|
|
||||||
|
|
||||||
if (addPoint) {
|
|
||||||
newPoints.push(point);
|
|
||||||
|
|
||||||
setPoints(newPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($el.current) {
|
|
||||||
const ctx = $el.current.getContext('2d');
|
|
||||||
|
|
||||||
if (ctx) {
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
|
||||||
ctx.imageSmoothingQuality = 'high';
|
|
||||||
|
|
||||||
const pathData = new Path2D(
|
|
||||||
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.fill(pathData);
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange?.($el.current.toDataURL());
|
|
||||||
}
|
|
||||||
|
|
||||||
setPoints([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
|
||||||
if (event.cancelable) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('buttons' in event && event.buttons === 1) {
|
|
||||||
onMouseDown(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseLeave = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
|
||||||
if (event.cancelable) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMouseUp(event, false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClearClick = () => {
|
|
||||||
if ($el.current) {
|
|
||||||
const ctx = $el.current.getContext('2d');
|
|
||||||
|
|
||||||
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange?.(null);
|
|
||||||
|
|
||||||
setPoints([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if ($el.current) {
|
|
||||||
$el.current.width = $el.current.clientWidth * DPI;
|
|
||||||
$el.current.height = $el.current.clientHeight * DPI;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative block">
|
|
||||||
<canvas
|
|
||||||
ref={$el}
|
|
||||||
className={cn('relative block', className)}
|
|
||||||
style={{ touchAction: 'none' }}
|
|
||||||
onPointerMove={(event) => onMouseMove(event)}
|
|
||||||
onPointerDown={(event) => onMouseDown(event)}
|
|
||||||
onPointerUp={(event) => onMouseUp(event)}
|
|
||||||
onPointerLeave={(event) => onMouseLeave(event)}
|
|
||||||
onPointerEnter={(event) => onMouseEnter(event)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute bottom-2 right-2">
|
|
||||||
<button className="rounded-full p-2 text-xs text-slate-500" onClick={() => onClearClick()}>
|
|
||||||
Clear Signature
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
27
apps/marketing/src/hooks/use-window-size.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useWindowSize() {
|
||||||
|
const [size, setSize] = useState({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
setSize({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onResize();
|
||||||
|
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
@ -43,7 +43,6 @@ export default async function handler(
|
|||||||
|
|
||||||
if (user && user.Subscription.length > 0) {
|
if (user && user.Subscription.length > 0) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -104,7 +103,6 @@ export default async function handler(
|
|||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
metadata,
|
metadata,
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
||||||
email,
|
email,
|
||||||
|
|||||||
@ -17,14 +17,13 @@ import {
|
|||||||
SigningStatus,
|
SigningStatus,
|
||||||
} from '@documenso/prisma/client';
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
const log = (...args: any[]) => console.log('[stripe]', ...args);
|
const log = (...args: unknown[]) => console.log('[stripe]', ...args);
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: { bodyParser: false },
|
api: { bodyParser: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
||||||
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
|
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
|
||||||
// return res.status(500).json({
|
// return res.status(500).json({
|
||||||
// success: false,
|
// success: false,
|
||||||
@ -55,6 +54,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
log('event-type:', event.type);
|
log('event-type:', event.type);
|
||||||
|
|
||||||
if (event.type === 'checkout.session.completed') {
|
if (event.type === 'checkout.session.completed') {
|
||||||
|
// This typecast is required since we don't want to create a guard for every event type
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const session = event.data.object as Stripe.Checkout.Session;
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
|
||||||
if (session.metadata?.source === 'landing') {
|
if (session.metadata?.source === 'landing') {
|
||||||
|
|||||||
@ -9,16 +9,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": [
|
"~/*": ["./src/*"],
|
||||||
"./src/*"
|
"contentlayer/generated": ["./.contentlayer/generated"]
|
||||||
],
|
|
||||||
"contentlayer/generated": [
|
|
||||||
"./.contentlayer/generated"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"types": [
|
"types": ["@documenso/lib/types/next-auth.d.ts"],
|
||||||
"@documenso/lib/types/next-auth.d.ts"
|
|
||||||
],
|
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"incremental": false
|
"incremental": false
|
||||||
},
|
},
|
||||||
@ -29,7 +23,5 @@
|
|||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".contentlayer/generated"
|
".contentlayer/generated"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": ["node_modules"]
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,14 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=3000 next dev",
|
"dev": "next dev -p 3000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@documenso/ee": "*",
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
@ -20,6 +22,7 @@
|
|||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.214.0",
|
"lucide-react": "^0.214.0",
|
||||||
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
@ -27,18 +30,22 @@
|
|||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
|
"posthog-js": "^1.75.3",
|
||||||
|
"posthog-node": "^3.1.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.8.0",
|
||||||
"react-pdf": "^7.1.1",
|
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
|
"sharp": "0.32.5",
|
||||||
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.1.6",
|
"typescript": "5.1.6",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7"
|
||||||
|
|||||||
2
apps/web/process-env.d.ts
vendored
@ -10,8 +10,6 @@ declare namespace NodeJS {
|
|||||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||||
|
|
||||||
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: string;
|
|
||||||
|
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
|
||||||
}
|
}
|
||||||
|
|||||||
56611
apps/web/public/pdf.worker.min.js
vendored
Normal file
@ -5,6 +5,7 @@ import { Clock, File, FileCheck } from 'lucide-react';
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||||
|
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -21,15 +22,33 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
|||||||
|
|
||||||
import { UploadDocument } from './upload-document';
|
import { UploadDocument } from './upload-document';
|
||||||
|
|
||||||
|
const CARD_DATA = [
|
||||||
|
{
|
||||||
|
icon: FileCheck,
|
||||||
|
title: 'Completed',
|
||||||
|
status: InternalDocumentStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: File,
|
||||||
|
title: 'Drafts',
|
||||||
|
status: InternalDocumentStatus.DRAFT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Clock,
|
||||||
|
title: 'Pending',
|
||||||
|
status: InternalDocumentStatus.PENDING,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await getRequiredServerComponentSession();
|
const user = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const [stats, results] = await Promise.all([
|
const [stats, results] = await Promise.all([
|
||||||
getStats({
|
getStats({
|
||||||
userId: session.id,
|
user,
|
||||||
}),
|
}),
|
||||||
findDocuments({
|
findDocuments({
|
||||||
userId: session.id,
|
userId: user.id,
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@ -39,15 +58,11 @@ export default async function DashboardPage() {
|
|||||||
<h1 className="text-4xl font-semibold">Dashboard</h1>
|
<h1 className="text-4xl font-semibold">Dashboard</h1>
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<Link href={'/documents?status=COMPLETED'} passHref>
|
{CARD_DATA.map((card) => (
|
||||||
<CardMetric icon={FileCheck} title="Completed" value={stats.COMPLETED} />
|
<Link key={card.status} href={`/documents?status=${card.status}`}>
|
||||||
</Link>
|
<CardMetric icon={card.icon} title={card.title} value={stats[card.status]} />
|
||||||
<Link href={'/documents?status=DRAFT'} passHref>
|
|
||||||
<CardMetric icon={File} title="Drafts" value={stats.DRAFT} />
|
|
||||||
</Link>
|
|
||||||
<Link href={'/documents?status=PENDING'} passHref>
|
|
||||||
<CardMetric icon={Clock} title="Pending" value={stats.PENDING} />
|
|
||||||
</Link>
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
|
|||||||
@ -5,10 +5,10 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCreateDocument } from '~/api/document/create/fetcher';
|
import { useCreateDocument } from '~/api/document/create/fetcher';
|
||||||
import { DocumentDropzone } from '~/components/(dashboard)/document-dropzone/document-dropzone';
|
|
||||||
|
|
||||||
export type UploadDocumentProps = {
|
export type UploadDocumentProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@ -2,28 +2,28 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
|
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
|
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
||||||
|
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||||
|
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
||||||
|
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||||
|
import {
|
||||||
|
DocumentFlowFormContainer,
|
||||||
|
DocumentFlowFormContainerHeader,
|
||||||
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
|
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { AddFieldsFormPartial } from '~/components/forms/edit-document/add-fields';
|
import { addFields } from '~/components/forms/edit-document/add-fields.action';
|
||||||
import { AddSignersFormPartial } from '~/components/forms/edit-document/add-signers';
|
import { addSigners } from '~/components/forms/edit-document/add-signers.action';
|
||||||
import { AddSubjectFormPartial } from '~/components/forms/edit-document/add-subject';
|
import { completeDocument } from '~/components/forms/edit-document/add-subject.action';
|
||||||
|
|
||||||
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>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type EditDocumentFormProps = {
|
export type EditDocumentFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -33,6 +33,8 @@ export type EditDocumentFormProps = {
|
|||||||
fields: Field[];
|
fields: Field[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
||||||
|
|
||||||
export const EditDocumentForm = ({
|
export const EditDocumentForm = ({
|
||||||
className,
|
className,
|
||||||
document,
|
document,
|
||||||
@ -40,27 +42,109 @@ export const EditDocumentForm = ({
|
|||||||
fields,
|
fields,
|
||||||
user: _user,
|
user: _user,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers');
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<EditDocumentStep>('signers');
|
||||||
|
|
||||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||||
|
|
||||||
const onNextStep = () => {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
if (step === 'signers') {
|
signers: {
|
||||||
setStep('fields');
|
title: 'Add Signers',
|
||||||
}
|
description: 'Add the people who will sign the document.',
|
||||||
|
stepIndex: 1,
|
||||||
|
onSubmit: () => onAddSignersFormSubmit,
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
title: 'Add Fields',
|
||||||
|
description: 'Add all relevant fields for each recipient.',
|
||||||
|
stepIndex: 2,
|
||||||
|
onBackStep: () => setStep('signers'),
|
||||||
|
onSubmit: () => onAddFieldsFormSubmit,
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
title: 'Add Subject',
|
||||||
|
description: 'Add the subject and message you wish to send to signers.',
|
||||||
|
stepIndex: 3,
|
||||||
|
onBackStep: () => setStep('fields'),
|
||||||
|
onSubmit: () => onAddSubjectFormSubmit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
if (step === 'fields') {
|
const currentDocumentFlow = documentFlow[step];
|
||||||
setStep('subject');
|
|
||||||
|
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||||
|
try {
|
||||||
|
// Custom invocation server action
|
||||||
|
await addSigners({
|
||||||
|
documentId: document.id,
|
||||||
|
signers: data.signers,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
setStep('fields');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while adding signers.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPreviousStep = () => {
|
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
||||||
if (step === 'fields') {
|
try {
|
||||||
setStep('signers');
|
// Custom invocation server action
|
||||||
}
|
await addFields({
|
||||||
|
documentId: document.id,
|
||||||
|
fields: data.fields,
|
||||||
|
});
|
||||||
|
|
||||||
if (step === 'subject') {
|
router.refresh();
|
||||||
setStep('fields');
|
|
||||||
|
setStep('subject');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while adding signers.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||||
|
const { subject, message } = data.email;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await completeDocument({
|
||||||
|
documentId: document.id,
|
||||||
|
email: {
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Document sent',
|
||||||
|
description: 'Your document has been sent successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while sending the document.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -71,40 +155,48 @@ export const EditDocumentForm = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer document={documentUrl} />
|
<LazyPDFViewer document={documentUrl} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
|
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
|
||||||
|
<DocumentFlowFormContainerHeader
|
||||||
|
title={currentDocumentFlow.title}
|
||||||
|
description={currentDocumentFlow.description}
|
||||||
|
/>
|
||||||
|
|
||||||
{step === 'signers' && (
|
{step === 'signers' && (
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
document={document}
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
onContinue={onNextStep}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
onGoBack={onPreviousStep}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'fields' && (
|
{step === 'fields' && (
|
||||||
<AddFieldsFormPartial
|
<AddFieldsFormPartial
|
||||||
|
documentFlow={documentFlow.fields}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
document={document}
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
onContinue={onNextStep}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
onGoBack={onPreviousStep}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'subject' && (
|
{step === 'subject' && (
|
||||||
<AddSubjectFormPartial
|
<AddSubjectFormPartial
|
||||||
|
documentFlow={documentFlow.subject}
|
||||||
|
document={document}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
document={document}
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
onContinue={onNextStep}
|
onSubmit={onAddSubjectFormSubmit}
|
||||||
onGoBack={onPreviousStep}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</DocumentFlowFormContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,34 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
import { PDFViewerProps } from '~/components/(dashboard)/pdf-viewer/pdf-viewer';
|
import { PDFViewerProps } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
export type LoadablePDFCard = PDFViewerProps & {
|
export type LoadablePDFCard = PDFViewerProps & {
|
||||||
className?: string;
|
className?: string;
|
||||||
pdfClassName?: string;
|
pdfClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
|
||||||
<Loader className="h-12 w-12 animate-spin text-slate-500" />
|
|
||||||
|
|
||||||
<p className="mt-4 text-slate-500">Loading document...</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
|
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
|
||||||
return (
|
return (
|
||||||
<Card className={className} gradient {...props}>
|
<Card className={className} gradient {...props}>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer className={pdfClassName} {...props} />
|
<LazyPDFViewer className={pdfClassName} {...props} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,12 +9,13 @@ export default function Loading() {
|
|||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Documents
|
Documents
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
Loading Document...
|
Loading Document...
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mt-8 grid min-h-[80vh] w-full grid-cols-12 gap-x-8">
|
<div className="mt-8 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
||||||
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
|
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||||
<div className="flex min-h-[80vh] flex-col items-center justify-center">
|
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
||||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||||
|
|||||||
@ -7,8 +7,11 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
|||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||||
|
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
export type DocumentPageProps = {
|
||||||
@ -69,11 +72,14 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
<div className="text-muted-foreground flex items-center">
|
<div className="text-muted-foreground flex items-center">
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
|
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<span>{recipients.length} Recipient(s)</span>
|
||||||
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{document.status !== InternalDocumentStatus.COMPLETED && (
|
||||||
<EditDocumentForm
|
<EditDocumentForm
|
||||||
className="mt-8"
|
className="mt-8"
|
||||||
document={document}
|
document={document}
|
||||||
@ -81,6 +87,13 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||||
|
<div className="mx-auto mt-12 max-w-2xl">
|
||||||
|
<LazyPDFViewer document={`data:application/pdf;base64,${document.document}`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Edit, Pencil, Share } from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export type DataTableActionButtonProps = {
|
||||||
|
row: Document & {
|
||||||
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
Recipient: Recipient[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||||
|
|
||||||
|
const isOwner = row.User.id === session.user.id;
|
||||||
|
const isRecipient = !!recipient;
|
||||||
|
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||||
|
const isPending = row.status === DocumentStatus.PENDING;
|
||||||
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
|
||||||
|
return match({
|
||||||
|
isOwner,
|
||||||
|
isRecipient,
|
||||||
|
isDraft,
|
||||||
|
isPending,
|
||||||
|
isComplete,
|
||||||
|
isSigned,
|
||||||
|
})
|
||||||
|
.with({ isOwner: true, isDraft: true }, () => (
|
||||||
|
<Button className="w-24" asChild>
|
||||||
|
<Link href={`/documents/${row.id}`}>
|
||||||
|
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
|
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||||
|
<Button className="w-24" asChild>
|
||||||
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
|
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
Sign
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<Button className="w-24" disabled>
|
||||||
|
<Share className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
));
|
||||||
|
};
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
Edit,
|
||||||
|
History,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Share,
|
||||||
|
Trash2,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
|
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
export type DataTableActionDropdownProps = {
|
||||||
|
row: Document & {
|
||||||
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
Recipient: Recipient[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||||
|
|
||||||
|
const isOwner = row.User.id === session.user.id;
|
||||||
|
// const isRecipient = !!recipient;
|
||||||
|
// const isDraft = row.status === DocumentStatus.DRAFT;
|
||||||
|
// const isPending = row.status === DocumentStatus.PENDING;
|
||||||
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
|
||||||
|
const onDownloadClick = () => {
|
||||||
|
let decodedDocument = row.document;
|
||||||
|
|
||||||
|
try {
|
||||||
|
decodedDocument = atob(decodedDocument);
|
||||||
|
} catch (err) {
|
||||||
|
// We're just going to ignore this error and try to download the document
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
|
||||||
|
|
||||||
|
const blob = new Blob([documentBytes], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = window.document.createElement('a');
|
||||||
|
|
||||||
|
link.href = window.URL.createObjectURL(blob);
|
||||||
|
link.download = row.title || 'document.pdf';
|
||||||
|
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
window.URL.revokeObjectURL(link.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<MoreHorizontal className="h-5 w-5 text-gray-500" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled={!recipient} asChild>
|
||||||
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Sign
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled={!isOwner} asChild>
|
||||||
|
<Link href={`/documents/${row.id}`}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
|
Void
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<History className="mr-2 h-4 w-4" />
|
||||||
|
Resend
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<Share className="mr-2 h-4 w-4" />
|
||||||
|
Share
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -8,7 +8,7 @@ import { Loader } from 'lucide-react';
|
|||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient';
|
import { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
|
||||||
@ -16,8 +16,16 @@ import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-a
|
|||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
import { DataTableActionButton } from './data-table-action-button';
|
||||||
|
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||||
|
|
||||||
export type DocumentsDataTableProps = {
|
export type DocumentsDataTableProps = {
|
||||||
results: FindResultSet<DocumentWithReciepient>;
|
results: FindResultSet<
|
||||||
|
Document & {
|
||||||
|
Recipient: Recipient[];
|
||||||
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||||
@ -45,7 +53,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
{
|
{
|
||||||
header: 'Title',
|
header: 'Title',
|
||||||
cell: ({ row }) => (
|
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}
|
{row.original.title}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
@ -67,6 +79,15 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
accessorKey: 'created',
|
accessorKey: 'created',
|
||||||
cell: ({ row }) => <LocaleDate date={row.getValue('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}
|
data={results.data}
|
||||||
perPage={results.perPage}
|
perPage={results.perPage}
|
||||||
|
|||||||
@ -1,28 +1,22 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Plus } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||||
import { isDocumentStatus } from '@documenso/lib/types/is-document-status';
|
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
import { DocumentDropzone } from '~/components/(dashboard)/document-dropzone/document-dropzone';
|
|
||||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||||
import {
|
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||||
PeriodSelectorValue,
|
|
||||||
isPeriodSelectorValue,
|
|
||||||
} from '~/components/(dashboard)/period-selector/types';
|
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
|
import { UploadDocument } from '../dashboard/upload-document';
|
||||||
import { DocumentsDataTable } from './data-table';
|
import { DocumentsDataTable } from './data-table';
|
||||||
|
|
||||||
export type DocumentsPageProps = {
|
export type DocumentsPageProps = {
|
||||||
searchParams?: {
|
searchParams?: {
|
||||||
status?: InternalDocumentStatus | 'ALL';
|
status?: ExtendedDocumentStatus;
|
||||||
period?: PeriodSelectorValue;
|
period?: PeriodSelectorValue;
|
||||||
page?: string;
|
page?: string;
|
||||||
perPage?: string;
|
perPage?: string;
|
||||||
@ -30,20 +24,20 @@ export type DocumentsPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||||
const session = await getRequiredServerComponentSession();
|
const user = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const stats = await getStats({
|
const stats = await getStats({
|
||||||
userId: session.id,
|
user,
|
||||||
});
|
});
|
||||||
|
|
||||||
const status = isDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||||
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
// const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||||
const page = Number(searchParams.page) || 1;
|
const page = Number(searchParams.page) || 1;
|
||||||
const perPage = Number(searchParams.perPage) || 20;
|
const perPage = Number(searchParams.perPage) || 20;
|
||||||
|
|
||||||
const results = await findDocuments({
|
const results = await findDocuments({
|
||||||
userId: session.id,
|
userId: user.id,
|
||||||
status: status === 'ALL' ? undefined : status,
|
status,
|
||||||
orderBy: {
|
orderBy: {
|
||||||
column: 'created',
|
column: 'created',
|
||||||
direction: 'desc',
|
direction: 'desc',
|
||||||
@ -52,8 +46,6 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
perPage,
|
perPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isNoResults = status === 'ALL' && period === '' && results.data.length === 0;
|
|
||||||
|
|
||||||
const getTabHref = (value: typeof status) => {
|
const getTabHref = (value: typeof status) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
@ -63,73 +55,47 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
params.delete('page');
|
params.delete('page');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === 'ALL') {
|
|
||||||
params.delete('status');
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/documents?${params.toString()}`;
|
return `/documents?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<h1 className="text-4xl font-semibold">All Documents</h1>
|
<UploadDocument />
|
||||||
|
|
||||||
|
<h1 className="mt-12 text-4xl font-semibold">Documents</h1>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
|
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
|
||||||
<Tabs defaultValue={status}>
|
<Tabs defaultValue={status} className="overflow-x-auto">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger className="min-w-[60px]" value="ALL" asChild>
|
{[
|
||||||
<Link href={getTabHref('ALL')}>All</Link>
|
ExtendedDocumentStatus.INBOX,
|
||||||
</TabsTrigger>
|
ExtendedDocumentStatus.PENDING,
|
||||||
|
ExtendedDocumentStatus.COMPLETED,
|
||||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.DRAFT} asChild>
|
ExtendedDocumentStatus.DRAFT,
|
||||||
<Link href={getTabHref(InternalDocumentStatus.DRAFT)}>
|
ExtendedDocumentStatus.ALL,
|
||||||
<DocumentStatus status={InternalDocumentStatus.DRAFT} />
|
].map((value) => (
|
||||||
|
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
|
||||||
|
<Link href={getTabHref(value)} scroll={false}>
|
||||||
|
<DocumentStatus status={value} />
|
||||||
|
|
||||||
|
{value !== ExtendedDocumentStatus.ALL && (
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||||
{Math.min(stats.DRAFT, 99)}
|
{Math.min(stats[value], 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>
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
<div className="flex flex-1 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||||
<PeriodSelector />
|
<PeriodSelector />
|
||||||
|
|
||||||
<Button>
|
|
||||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
|
||||||
Add Document
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
{/* If we're viewing all documents for all time and there's nuffin we should should an add document component instead */}
|
|
||||||
{isNoResults ? (
|
|
||||||
<DocumentDropzone className="min-h-[60vh] md:min-h-[40vh]" />
|
|
||||||
) : (
|
|
||||||
<DocumentsDataTable results={results} />
|
<DocumentsDataTable results={results} />
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
|
||||||
import { Header } from '~/components/(dashboard)/layout/header';
|
import { Header } from '~/components/(dashboard)/layout/header';
|
||||||
|
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||||
import { NextAuthProvider } from '~/providers/next-auth';
|
import { NextAuthProvider } from '~/providers/next-auth';
|
||||||
|
|
||||||
export type AuthenticatedDashboardLayoutProps = {
|
export type AuthenticatedDashboardLayoutProps = {
|
||||||
@ -30,6 +31,8 @@ export default async function AuthenticatedDashboardLayout({
|
|||||||
<Header user={user} />
|
<Header user={user} />
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
|
||||||
|
<RefreshOnFocus />
|
||||||
</NextAuthProvider>
|
</NextAuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,75 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||||
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||||
|
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { PasswordForm } from '~/components/forms/password';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
|
||||||
|
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
const user = await getRequiredServerComponentSession();
|
const user = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
||||||
|
|
||||||
// Redirect if subscriptions are not enabled.
|
// Redirect if subscriptions are not enabled.
|
||||||
if (!IS_SUBSCRIPTIONS_ENABLED) {
|
if (!isBillingEnabled) {
|
||||||
redirect('/settings/profile');
|
redirect('/settings/profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let subscription = await getSubscriptionByUserId({ userId: user.id });
|
||||||
|
|
||||||
|
// If we don't have a customer record, create one as well as an empty subscription.
|
||||||
|
if (!subscription?.customerId) {
|
||||||
|
subscription = await createCustomer({ user });
|
||||||
|
}
|
||||||
|
|
||||||
|
let billingPortalUrl = '';
|
||||||
|
|
||||||
|
if (subscription?.customerId) {
|
||||||
|
billingPortalUrl = await getPortalSession({
|
||||||
|
customerId: subscription.customerId,
|
||||||
|
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Billing</h3>
|
<h3 className="text-lg font-medium">Billing</h3>
|
||||||
|
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
Here you can update and manage your subscription.
|
Your subscription is{' '}
|
||||||
|
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
|
||||||
|
{subscription?.periodEnd && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
Your next payment is due on{' '}
|
||||||
|
<span className="font-semibold">
|
||||||
|
<LocaleDate date={subscription.periodEnd} />
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
<PasswordForm user={user} className="max-w-xl" />
|
{billingPortalUrl && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={billingPortalUrl}>Manage Subscription</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!billingPortalUrl && (
|
||||||
|
<p className="max-w-[60ch] text-base text-slate-500">
|
||||||
|
You do not currently have a customer record, this should not happen. Please contact
|
||||||
|
support for assistance.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
107
apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
94
apps/web/src/app/(signing)/sign/[token]/date-field.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
96
apps/web/src/app/(signing)/sign/[token]/email-field.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Recipient } from '@documenso/prisma/client';
|
||||||
|
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useRequiredSigningContext } from './provider';
|
||||||
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
|
export type EmailFieldProps = {
|
||||||
|
field: FieldWithSignature;
|
||||||
|
recipient: Recipient;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { email: providedEmail } = useRequiredSigningContext();
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
|
trpc.field.signFieldWithToken.useMutation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
|
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||||
|
|
||||||
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
|
const onSign = async () => {
|
||||||
|
try {
|
||||||
|
await signFieldWithToken({
|
||||||
|
token: recipient.token,
|
||||||
|
fieldId: field.id,
|
||||||
|
value: providedEmail ?? '',
|
||||||
|
isBase64: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while signing the document.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemove = async () => {
|
||||||
|
try {
|
||||||
|
await removeSignedFieldWithToken({
|
||||||
|
token: recipient.token,
|
||||||
|
fieldId: field.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while removing the signature.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
||||||
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!field.inserted && (
|
||||||
|
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Email</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
|
||||||
|
</SigningFieldContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
123
apps/web/src/app/(signing)/sign/[token]/form.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
'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) => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
apps/web/src/app/(signing)/sign/[token]/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
apps/web/src/app/(signing)/sign/[token]/name-field.tsx
Normal 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);
|
||||||
|
void onSign('local');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</SigningFieldContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
100
apps/web/src/app/(signing)/sign/[token]/page.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
|
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 { EmailField } from './email-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 user = await getServerComponentSession();
|
||||||
|
|
||||||
|
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SigningProvider email={recipient.email} fullName={recipient.name} signature={user?.signature}>
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
|
{document.title}
|
||||||
|
</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} />
|
||||||
|
))
|
||||||
|
.with(FieldType.EMAIL, () => (
|
||||||
|
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
|
.otherwise(() => null),
|
||||||
|
)}
|
||||||
|
</ElementVisible>
|
||||||
|
</div>
|
||||||
|
</SigningProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/web/src/app/(signing)/sign/[token]/provider.tsx
Normal 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 | null;
|
||||||
|
email?: string | null;
|
||||||
|
signature?: string | null;
|
||||||
|
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';
|
||||||
192
apps/web/src/app/(signing)/sign/[token]/signature-field.tsx
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
'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 {
|
||||||
|
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">
|
||||||
|
{/* This optional chaining is intentional, we don't want to move the check into the condition above */}
|
||||||
|
{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);
|
||||||
|
void onSign('local');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</SigningFieldContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 { TrpcProvider } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||||
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
|
import { getServerComponentAllFlags } from '~/helpers/get-server-component-feature-flag';
|
||||||
|
import { FeatureFlagProvider } from '~/providers/feature-flag';
|
||||||
import { ThemeProvider } from '~/providers/next-theme';
|
import { ThemeProvider } from '~/providers/next-theme';
|
||||||
import { PlausibleProvider } from '~/providers/plausible';
|
import { PlausibleProvider } from '~/providers/plausible';
|
||||||
|
import { PostHogPageview } from '~/providers/posthog';
|
||||||
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||||
|
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
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 (
|
return (
|
||||||
<html lang="en" className={fontInter.variable} suppressHydrationWarning>
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={cn(fontInter.variable, fontCaveat.variable)}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
<head>
|
<head>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
@ -45,7 +58,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
<Suspense>
|
||||||
|
<PostHogPageview />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<FeatureFlagProvider initialFlags={flags}>
|
||||||
<PlausibleProvider>
|
<PlausibleProvider>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@ -54,6 +72,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</PlausibleProvider>
|
</PlausibleProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
</FeatureFlagProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 337 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 394 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 178 KiB |
BIN
apps/web/src/assets/signing-celebration.png
Normal file
|
After Width: | Height: | Size: 14 MiB |
@ -12,7 +12,7 @@ export type StackAvatarProps = {
|
|||||||
first?: boolean;
|
first?: boolean;
|
||||||
zIndex?: string;
|
zIndex?: string;
|
||||||
fallbackText?: string;
|
fallbackText?: string;
|
||||||
type: 'unsigned' | 'waiting' | 'completed';
|
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => {
|
export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => {
|
||||||
@ -28,6 +28,9 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr
|
|||||||
case 'unsigned':
|
case 'unsigned':
|
||||||
classes = 'bg-dawn-200 text-dawn-900';
|
classes = 'bg-dawn-200 text-dawn-900';
|
||||||
break;
|
break;
|
||||||
|
case 'opened':
|
||||||
|
classes = 'bg-yellow-200 text-yellow-700';
|
||||||
|
break;
|
||||||
case 'waiting':
|
case 'waiting':
|
||||||
classes = 'bg-water text-water-700';
|
classes = 'bg-water text-water-700';
|
||||||
break;
|
break;
|
||||||
@ -43,7 +46,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr
|
|||||||
className={`
|
className={`
|
||||||
${zIndexClass}
|
${zIndexClass}
|
||||||
${firstClass}
|
${firstClass}
|
||||||
h-10 w-10 border-2 border-solid border-white`}
|
dark:border-border h-10 w-10 border-2 border-solid border-white`}
|
||||||
>
|
>
|
||||||
<AvatarFallback className={classes}>{fallbackText ?? 'UK'}</AvatarFallback>
|
<AvatarFallback className={classes}>{fallbackText ?? 'UK'}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@ -11,26 +11,41 @@ import {
|
|||||||
import { StackAvatar } from './stack-avatar';
|
import { StackAvatar } from './stack-avatar';
|
||||||
import { StackAvatars } from './stack-avatars';
|
import { StackAvatars } from './stack-avatars';
|
||||||
|
|
||||||
export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[] }) => {
|
export type StackAvatarsWithTooltipProps = {
|
||||||
|
recipients: Recipient[];
|
||||||
|
position?: 'top' | 'bottom';
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StackAvatarsWithTooltip = ({
|
||||||
|
recipients,
|
||||||
|
position,
|
||||||
|
children,
|
||||||
|
}: StackAvatarsWithTooltipProps) => {
|
||||||
const waitingRecipients = recipients.filter(
|
const waitingRecipients = recipients.filter(
|
||||||
(recipient) => recipient.sendStatus === 'SENT' && recipient.signingStatus === 'NOT_SIGNED',
|
(recipient) => getRecipientType(recipient) === 'waiting',
|
||||||
|
);
|
||||||
|
|
||||||
|
const openedRecipients = recipients.filter(
|
||||||
|
(recipient) => getRecipientType(recipient) === 'opened',
|
||||||
);
|
);
|
||||||
|
|
||||||
const completedRecipients = recipients.filter(
|
const completedRecipients = recipients.filter(
|
||||||
(recipient) => recipient.sendStatus === 'SENT' && recipient.signingStatus === 'SIGNED',
|
(recipient) => getRecipientType(recipient) === 'completed',
|
||||||
);
|
);
|
||||||
|
|
||||||
const uncompletedRecipients = recipients.filter(
|
const uncompletedRecipients = recipients.filter(
|
||||||
(recipient) => recipient.sendStatus === 'NOT_SENT' && recipient.signingStatus === 'NOT_SIGNED',
|
(recipient) => getRecipientType(recipient) === 'unsigned',
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="flex cursor-pointer">
|
<TooltipTrigger className="flex cursor-pointer">
|
||||||
<StackAvatars recipients={recipients} />
|
{children || <StackAvatars recipients={recipients} />}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
|
||||||
|
<TooltipContent side={position}>
|
||||||
<div className="flex flex-col gap-y-5 p-1">
|
<div className="flex flex-col gap-y-5 p-1">
|
||||||
{completedRecipients.length > 0 && (
|
{completedRecipients.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
@ -66,6 +81,23 @@ export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[
|
|||||||
</div>
|
</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 && (
|
{uncompletedRecipients.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-medium">Uncompleted</h1>
|
<h1 className="text-base font-medium">Uncompleted</h1>
|
||||||
|
|||||||
@ -2,30 +2,17 @@
|
|||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||||
const pathname = usePathname();
|
// const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('ml-8 hidden flex-1 gap-x-6 md:flex', className)} {...props}>
|
<div className={cn('ml-8 hidden flex-1 gap-x-6 md:flex', className)} {...props}>
|
||||||
<Link
|
{/* We have no other subpaths rn */}
|
||||||
href="/dashboard"
|
{/* <Link
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
|
||||||
{
|
|
||||||
'text-foreground': pathname?.startsWith('/dashboard'),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/documents"
|
href="/documents"
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||||
@ -35,14 +22,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Documents
|
Documents
|
||||||
</Link>
|
|
||||||
{/* <Link
|
|
||||||
href="/settings/profile"
|
|
||||||
className={cn('font-medium leading-5 text-[#A1A1AA] hover:opacity-80', {
|
|
||||||
'text-primary-foreground': pathname?.startsWith('/settings'),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</Link> */}
|
</Link> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,11 +4,8 @@ import { HTMLAttributes } from 'react';
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Menu } from 'lucide-react';
|
|
||||||
|
|
||||||
import { User } from '@documenso/prisma/client';
|
import { User } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { Logo } from '~/components/branding/logo';
|
import { Logo } from '~/components/branding/logo';
|
||||||
|
|
||||||
@ -23,7 +20,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
|||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-40 flex h-16 w-full items-center border-b backdrop-blur',
|
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b backdrop-blur',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -41,9 +38,9 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
|||||||
<div className="flex gap-x-4">
|
<div className="flex gap-x-4">
|
||||||
<ProfileDropdown user={user} />
|
<ProfileDropdown user={user} />
|
||||||
|
|
||||||
<Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
||||||
<Menu className="h-6 w-6" />
|
<Menu className="h-6 w-6" />
|
||||||
</Button>
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import {
|
|||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
|
||||||
import { User } from '@documenso/prisma/client';
|
import { User } from '@documenso/prisma/client';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -28,11 +27,19 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '~/providers/feature-flag';
|
||||||
|
|
||||||
export type ProfileDropdownProps = {
|
export type ProfileDropdownProps = {
|
||||||
user: User;
|
user: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
|
||||||
const initials =
|
const initials =
|
||||||
user.name
|
user.name
|
||||||
?.split(' ')
|
?.split(' ')
|
||||||
@ -40,8 +47,6 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.join('') ?? 'UK';
|
.join('') ?? 'UK';
|
||||||
|
|
||||||
const { theme, setTheme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@ -69,7 +74,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{IS_SUBSCRIPTIONS_ENABLED && (
|
{isBillingEnabled && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/billing" className="cursor-pointer">
|
<Link href="/settings/billing" className="cursor-pointer">
|
||||||
<CreditCard className="mr-2 h-4 w-4" />
|
<CreditCard className="mr-2 h-4 w-4" />
|
||||||
@ -113,7 +118,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
signOut({
|
void signOut({
|
||||||
callbackUrl: '/',
|
callbackUrl: '/',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export const PeriodSelector = () => {
|
|||||||
params.delete('period');
|
params.delete('period');
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push(`${pathname}?${params.toString()}`);
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||||
|
|
||||||
export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
|
export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
return ['', '7d', '14d', '30d'].includes(value as string);
|
return ['', '7d', '14d', '30d'].includes(value as string);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
@ -7,15 +7,20 @@ import { usePathname } from 'next/navigation';
|
|||||||
|
|
||||||
import { CreditCard, Key, User } from 'lucide-react';
|
import { CreditCard, Key, User } from 'lucide-react';
|
||||||
|
|
||||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '~/providers/feature-flag';
|
||||||
|
|
||||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||||
<Link href="/settings/profile">
|
<Link href="/settings/profile">
|
||||||
@ -44,7 +49,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{IS_SUBSCRIPTIONS_ENABLED && (
|
{isBillingEnabled && (
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -7,15 +7,20 @@ import { usePathname } from 'next/navigation';
|
|||||||
|
|
||||||
import { CreditCard, Key, User } from 'lucide-react';
|
import { CreditCard, Key, User } from 'lucide-react';
|
||||||
|
|
||||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '~/providers/feature-flag';
|
||||||
|
|
||||||
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
|
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
||||||
@ -47,7 +52,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{IS_SUBSCRIPTIONS_ENABLED && (
|
{isBillingEnabled && (
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Github } from 'lucide-react';
|
|
||||||
import { usePlausible } from 'next-plausible';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
export type CalloutProps = {
|
|
||||||
starCount?: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Callout = ({ starCount }: CalloutProps) => {
|
|
||||||
const event = usePlausible();
|
|
||||||
|
|
||||||
const onSignUpClick = () => {
|
|
||||||
const el = document.getElementById('email');
|
|
||||||
|
|
||||||
if (el) {
|
|
||||||
const { top } = el.getBoundingClientRect();
|
|
||||||
|
|
||||||
window.scrollTo({
|
|
||||||
top: top - 120,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
el.focus();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
|
||||||
onClick={onSignUpClick}
|
|
||||||
>
|
|
||||||
Get the Community Plan
|
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
|
||||||
$30/mo. forever!
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://github.com/documenso/documenso"
|
|
||||||
target="_blank"
|
|
||||||
onClick={() => event('view-github')}
|
|
||||||
>
|
|
||||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
|
||||||
<Github className="mr-2 h-5 w-5" />
|
|
||||||
Star on Github
|
|
||||||
{starCount && starCount > 0 && (
|
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
|
||||||
{starCount.toLocaleString('en-US')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Info, Loader } from 'lucide-react';
|
|
||||||
import { usePlausible } from 'next-plausible';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
|
||||||
|
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
|
||||||
|
|
||||||
export const ZClaimPlanDialogFormSchema = z.object({
|
|
||||||
name: z.string().min(3),
|
|
||||||
email: z.string().email(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TClaimPlanDialogFormSchema = z.infer<typeof ZClaimPlanDialogFormSchema>;
|
|
||||||
|
|
||||||
export type ClaimPlanDialogProps = {
|
|
||||||
className?: string;
|
|
||||||
planId: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
|
|
||||||
const params = useSearchParams();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const event = usePlausible();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<TClaimPlanDialogFormSchema>({
|
|
||||||
mode: 'onBlur',
|
|
||||||
defaultValues: {
|
|
||||||
name: params?.get('name') ?? '',
|
|
||||||
email: params?.get('email') ?? '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZClaimPlanDialogFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
|
|
||||||
try {
|
|
||||||
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
const [redirectUrl] = await Promise.all([
|
|
||||||
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
|
|
||||||
delay,
|
|
||||||
]);
|
|
||||||
|
|
||||||
event('claim-plan-pricing');
|
|
||||||
|
|
||||||
window.location.href = redirectUrl;
|
|
||||||
} catch (error) {
|
|
||||||
event('claim-plan-failed');
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: error instanceof Error ? error.message : 'Please try again later.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Claim your plan</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
We're almost there! Please enter your email address and name to claim your plan.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form
|
|
||||||
className={cn('flex flex-col gap-y-4', className)}
|
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
|
||||||
>
|
|
||||||
{params?.get('cancelled') === 'true' && (
|
|
||||||
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Info className="h-5 w-5 text-yellow-400" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<p className="text-sm leading-5 text-yellow-700">
|
|
||||||
You have cancelled the payment process. If you didn't mean to do this, please
|
|
||||||
try again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-slate-500">Name</Label>
|
|
||||||
|
|
||||||
<Input type="text" className="mt-2" {...register('name')} autoFocus />
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-1" error={errors.name} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-slate-500">Email</Label>
|
|
||||||
|
|
||||||
<Input type="email" className="mt-2" {...register('email')} />
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-1" error={errors.email} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" size="lg" disabled={isSubmitting}>
|
|
||||||
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
Claim the Community Plan ({/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
|
|
||||||
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
|
||||||
? 'Monthly'
|
|
||||||
: 'Yearly'}
|
|
||||||
)
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
import cardBeautifulFigure from '~/assets/card-beautiful-figure.png';
|
|
||||||
import cardFastFigure from '~/assets/card-fast-figure.png';
|
|
||||||
import cardSmartFigure from '~/assets/card-smart-figure.png';
|
|
||||||
|
|
||||||
export type FasterSmarterBeautifulBentoProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const FasterSmarterBeautifulBento = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: FasterSmarterBeautifulBentoProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn('relative', className)} {...props}>
|
|
||||||
<div className="absolute inset-0 -z-10 flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src={backgroundPattern}
|
|
||||||
alt="background pattern"
|
|
||||||
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
|
||||||
A 10x better signing experience.
|
|
||||||
<span className="block md:mt-0">Faster, smarter and more beautiful.</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
|
||||||
<Card className="col-span-2" degrees={45} gradient>
|
|
||||||
<CardContent className="grid grid-cols-12 gap-8 overflow-hidden p-6 lg:aspect-[2.5/1]">
|
|
||||||
<p className="col-span-12 leading-relaxed text-[#555E67] lg:col-span-6">
|
|
||||||
<strong className="block">Fast.</strong>
|
|
||||||
When it comes to sending or receiving a contract, you can count on lightning-fast
|
|
||||||
speeds.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
|
|
||||||
<Image src={cardFastFigure} alt="its fast" className="max-w-[80%] lg:max-w-none" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
|
||||||
<strong className="block">Beautiful.</strong>
|
|
||||||
Because signing should be celebrated. That’s why we care about the smallest detail in
|
|
||||||
our product.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Image src={cardBeautifulFigure} alt="its fast" className="w-full max-w-xs" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
|
||||||
<strong className="block">Smart.</strong>
|
|
||||||
Our custom templates come with smart rules that can help you save time and energy.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Image src={cardSmartFigure} alt="its fast" className="w-full max-w-[16rem]" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Github, Slack, Twitter } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn('border-t py-12', className)} {...props}>
|
|
||||||
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
|
||||||
<div>
|
|
||||||
<Link href="/">
|
|
||||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={0}></Image>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-4 text-[#8D8D8D]">
|
|
||||||
<Link
|
|
||||||
href="https://twitter.com/documenso"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<Twitter className="h-6 w-6" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://github.com/documenso/documenso"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<Github className="h-6 w-6" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://documenso.slack.com"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
<Slack className="h-6 w-6" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
|
||||||
<Link
|
|
||||||
href="/pricing"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Pricing
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://status.documenso.com"
|
|
||||||
target="_blank"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Status
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="mailto:support@documenso.com"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Support
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* <Link
|
|
||||||
href="/privacy"
|
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Privacy
|
|
||||||
</Link> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mx-auto mt-4 w-full max-w-screen-xl px-8 md:mt-12 lg:mt-24">
|
|
||||||
<p className="text-sm text-[#8D8D8D]">
|
|
||||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type HeaderProps = HTMLAttributes<HTMLElement>;
|
|
||||||
|
|
||||||
export const Header = ({ className, ...props }: HeaderProps) => {
|
|
||||||
return (
|
|
||||||
<header className={cn('flex items-center justify-between', className)} {...props}>
|
|
||||||
<Link href="/">
|
|
||||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={0}></Image>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-x-6">
|
|
||||||
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
|
||||||
Pricing
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="https://app.documenso.com/login"
|
|
||||||
target="_blank"
|
|
||||||
className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,225 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Variants, motion } from 'framer-motion';
|
|
||||||
import { Github } from 'lucide-react';
|
|
||||||
import { usePlausible } from 'next-plausible';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
|
|
||||||
import { Widget } from './widget';
|
|
||||||
|
|
||||||
export type HeroProps = {
|
|
||||||
className?: string;
|
|
||||||
starCount?: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
const BackgroundPatternVariants: Variants = {
|
|
||||||
initial: {
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
|
|
||||||
animate: {
|
|
||||||
opacity: 1,
|
|
||||||
|
|
||||||
transition: {
|
|
||||||
delay: 1,
|
|
||||||
duration: 1.2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const HeroTitleVariants: Variants = {
|
|
||||||
initial: {
|
|
||||||
opacity: 0,
|
|
||||||
y: 60,
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Hero = ({ className, starCount, ...props }: HeroProps) => {
|
|
||||||
const event = usePlausible();
|
|
||||||
|
|
||||||
const onSignUpClick = () => {
|
|
||||||
const el = document.getElementById('email');
|
|
||||||
|
|
||||||
if (el) {
|
|
||||||
const { top } = el.getBoundingClientRect();
|
|
||||||
|
|
||||||
window.scrollTo({
|
|
||||||
top: top - 120,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
el.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div className={cn('relative', className)} {...props}>
|
|
||||||
<div className="absolute -inset-24 -z-10">
|
|
||||||
<motion.div
|
|
||||||
className="flex h-full w-full origin-top-right items-center justify-center"
|
|
||||||
variants={BackgroundPatternVariants}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={backgroundPattern}
|
|
||||||
alt="background pattern"
|
|
||||||
className="-mr-[50vw] -mt-[15vh] h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<motion.h2
|
|
||||||
variants={HeroTitleVariants}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
className="text-center text-4xl font-bold leading-tight tracking-tight lg:text-[64px]"
|
|
||||||
>
|
|
||||||
Document signing,
|
|
||||||
<span className="block" /> finally open source.
|
|
||||||
</motion.h2>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
variants={HeroTitleVariants}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
|
||||||
onClick={onSignUpClick}
|
|
||||||
>
|
|
||||||
Get the Community Plan
|
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
|
||||||
$30/mo. forever!
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
|
|
||||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
|
||||||
<Github className="mr-2 h-5 w-5" />
|
|
||||||
Star on Github
|
|
||||||
{starCount && starCount > 0 && (
|
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
|
||||||
{starCount.toLocaleString('en-US')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-6">
|
|
||||||
<motion.div
|
|
||||||
variants={HeroTitleVariants}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
className="mt-8 flex flex-col items-center justify-center gap-x-6 gap-y-4"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href="https://www.producthunt.com/posts/documenso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-documenso"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=395047&theme=light&period=daily"
|
|
||||||
alt="Documenso - The open source DocuSign alternative | Product Hunt"
|
|
||||||
style={{ width: '250px', height: '54px' }}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="mt-12"
|
|
||||||
variants={{
|
|
||||||
initial: {
|
|
||||||
scale: 0.2,
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
scale: 1,
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
ease: 'easeInOut',
|
|
||||||
delay: 0.5,
|
|
||||||
duration: 0.8,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
>
|
|
||||||
<Widget className="mt-12">
|
|
||||||
<strong>Documenso Supporter Pledge</strong>
|
|
||||||
<p className="w-full max-w-[70ch]">
|
|
||||||
Our mission is to create an open signing infrastructure that empowers the world,
|
|
||||||
enabling businesses to embrace openness, cooperation, and transparency. We believe
|
|
||||||
that signing, as a fundamental act, should embody these values. By offering an
|
|
||||||
open-source signing solution, we aim to make document signing accessible, transparent,
|
|
||||||
and trustworthy.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="w-full max-w-[70ch]">
|
|
||||||
Through our platform, called Documenso, we strive to earn your trust by allowing
|
|
||||||
self-hosting and providing complete visibility into its inner workings. We value
|
|
||||||
inclusivity and foster an environment where diverse perspectives and contributions are
|
|
||||||
welcomed, even though we may not implement them all.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="w-full max-w-[70ch]">
|
|
||||||
At Documenso, we envision a web-enabled future for business and contracts, and we are
|
|
||||||
committed to being the leading provider of open signing infrastructure. By combining
|
|
||||||
exceptional product design with open-source principles, we aim to deliver a robust and
|
|
||||||
well-designed application that exceeds your expectations.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="w-full max-w-[70ch]">
|
|
||||||
We understand that exceptional products are born from exceptional communities, and we
|
|
||||||
invite you to join our open-source community. Your contributions, whether technical or
|
|
||||||
non-technical, will help shape the future of signing. Together, we can create a better
|
|
||||||
future for everyone.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="w-full max-w-[70ch]">
|
|
||||||
Today we invite you to join us on this journey: By signing this mission statement you
|
|
||||||
signal your support of Documenso's mission{' '}
|
|
||||||
<span className="bg-primary text-black">
|
|
||||||
(in a non-legally binding, but heartfelt way)
|
|
||||||
</span>{' '}
|
|
||||||
and lock in the early supporter plan for forever, including everything we build this
|
|
||||||
year.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex h-24 items-center">
|
|
||||||
<p className={cn('text-5xl [font-family:var(--font-caveat)]')}>Timur & Lucas</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<strong>Timur Ercan & Lucas Smith</strong>
|
|
||||||
<p className="mt-1">Co-Founders, Documenso</p>
|
|
||||||
</div>
|
|
||||||
</Widget>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
import cardBuildFigure from '~/assets/card-build-figure.png';
|
|
||||||
import cardOpenFigure from '~/assets/card-open-figure.png';
|
|
||||||
import cardTemplateFigure from '~/assets/card-template-figure.png';
|
|
||||||
|
|
||||||
export type OpenBuildTemplateBentoProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplateBentoProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn('relative', className)} {...props}>
|
|
||||||
<div className="absolute inset-0 -z-10 flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src={backgroundPattern}
|
|
||||||
alt="background pattern"
|
|
||||||
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
|
||||||
Truly your own.
|
|
||||||
<span className="block md:mt-0">Customise and expand.</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
|
||||||
<Card className="col-span-2" degrees={45} gradient>
|
|
||||||
<CardContent className="grid grid-cols-12 gap-8 overflow-hidden p-6 lg:aspect-[2.5/1]">
|
|
||||||
<p className="col-span-12 leading-relaxed text-[#555E67] lg:col-span-6">
|
|
||||||
<strong className="block">Open Source or Hosted.</strong>
|
|
||||||
It’s up to you. Either clone our repository or rely on our easy to use hosting
|
|
||||||
solution.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
|
|
||||||
<Image src={cardOpenFigure} alt="its fast" className="max-w-[80%] lg:max-w-full" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
|
||||||
<strong className="block">Build on top.</strong>
|
|
||||||
Make it your own through advanced customization and adjustability.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Image src={cardBuildFigure} alt="its fast" className="w-full max-w-xs" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
|
||||||
<strong className="block">Template Store (Soon).</strong>
|
|
||||||
Choose a template from the community app store. Or submit your own template for others
|
|
||||||
to use.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Image src={cardTemplateFigure} alt="its fast" className="w-full max-w-sm" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||