Compare commits

..

85 Commits

Author SHA1 Message Date
3af26291c9 minor changes 2023-07-08 08:56:34 +00:00
d1bc948f3c clean up console.log() used for testing 2023-06-13 02:00:45 -04:00
2b84636993 feat: google auth without schema change 2023-06-13 01:53:12 -04:00
05238f096b feat: dark mode & theme switching
feat: dark mode & theme switching
2023-06-12 16:01:02 +10:00
dd83d4607c fix: dark mode on signup and signin pages 2023-06-11 12:26:47 -04:00
07d13c74f5 fix: signature pad in dark mode 2023-06-11 11:36:13 -04:00
64d1d6df37 resolving eslint build errors 2023-06-11 02:21:13 -04:00
877a579533 adding dark mode to feat/refresh 2023-06-11 01:50:19 -04:00
b0e364acf4 wip: create document workflow 2023-06-10 22:33:12 +10:00
803ebccee3 wip: refresh design 2023-06-09 18:22:21 +10:00
5ec97657c1 Merge pull request #199 from documenso/fix/new-slack-link
fix: expired slack link
2023-06-07 20:15:13 +10:00
02f9c38e1e Replace slack link with documen.so/slack 2023-06-07 09:59:40 +00:00
aa651fb4e0 Merge pull request #191 from eltociear/patch-1
Fix typo in pdf-editor.tsx
2023-06-06 19:01:31 +10:00
9ff8527336 fix: expired slack link 2023-06-05 21:49:39 +00:00
4e65ff3a47 Merge pull request #195 from PeerRich/patch-1
chore: fix readme Product Hunt Badges
2023-06-05 21:47:39 +10:00
effe781ce7 chore: fix readme Product Hunt Badges
Product Hunt is over, its probably better to move it into its own section.

also added product of the day!
2023-06-05 12:33:08 +01:00
a1bb360b6f Fix typo in pdf-editor.tsx
postion -> position
2023-06-03 21:58:14 +09:00
11c1b6841f Merge pull request #185 from ahiho/fix/ipv6
docs: update troubleshooting for IPv6
2023-06-03 00:44:04 +10:00
c41007e026 Revert "fix: support ipv6 for nextjs"
This reverts commit f9de6671e0aa29e25e872a80aa334d3319e3e522.
2023-06-02 18:04:52 +07:00
db99bf3674 Revert "fix: custom nextjs server"
This reverts commit 8f9a5f4ec7d834970a3e2b0778ce94218c997a8f.
2023-06-02 18:04:52 +07:00
3caa01ab53 Revert "fix: add custom nextjs server to docker"
This reverts commit 5dbe7b26286234db542921d9ded000c522c9a31e.
2023-06-02 18:04:52 +07:00
20b618c70f docs: update troubleshooting for IPv6 2023-06-02 18:04:52 +07:00
bbedd6d3de fix: add custom nextjs server to docker 2023-06-02 18:04:52 +07:00
054480500f fix: custom nextjs server 2023-06-02 18:04:52 +07:00
15b5f31a74 fix: support ipv6 for nextjs 2023-06-02 18:04:52 +07:00
316fb49339 fix: disable subscriptions in example env 2023-06-02 19:03:59 +10:00
fc1b3be5ad Merge pull request #184 from The-Robin-Hood/bugfix/docker_script_update
docker script updated 🐳
2023-06-02 18:26:21 +10:00
20b51198b4 docker script updated 🐳 2023-06-01 22:24:58 +05:30
f80edf3f94 Merge pull request #181 from ahiho/fix/docker-image-typo
typo: documentso >> documenso
2023-06-02 00:06:28 +10:00
08faabc813 Merge pull request #182 from JonasPardon/patch-1
Fix typos in example env
2023-06-02 00:04:50 +10:00
0a7ed0701c fix: add turbo entries for other platforms to package-lock
Package managers such as NPM behave strangely when adding
packages such as swc and turborepo which contain platform
variants.

During a first time install they will include only the current
devices platform while a clean node_modules and package-lock
will result in all platforms being included.

This change adds those missed platforms by performing the above step and porting it back to our existing package-lock.
2023-06-01 23:25:49 +10:00
488cf58f0e Fix typos in example env
Just noticed some typos while setting up a local copy and thought I'd fix them up real quick.
2023-06-01 10:04:26 +02:00
dd4568b7fa typo: documentso >> documenso 2023-06-01 13:58:18 +07:00
893ab9bea5 Merge pull request #167 from dephraiim/fix/dashboard-logo
fix: dashboard logo
2023-05-30 23:29:57 +10:00
2aaeab3217 Merge pull request #176 from The-Robin-Hood/bugfix/docker_compose
Package.json Docker script fix
2023-05-30 22:50:57 +10:00
0a5de18235 minor script fix 2023-05-30 15:43:58 +05:30
b5e03359c1 fix: mark truncateTitle as optional 2023-05-30 20:11:23 +10:00
a266e4f9d4 Merge pull request #150 from The-Robin-Hood/bugfix/long_filename
Long filename fix 🗄
2023-05-30 20:04:19 +10:00
eccd9b5cd3 Merge branch 'main' into bugfix/long_filename 2023-05-30 20:03:19 +10:00
fdbcf33210 Merge pull request #164 from doug-andrade/next
Simplified next.config.js and removed next-transpile-modules dependency.
2023-05-30 20:02:17 +10:00
6048555e4a Merge branch 'main' into next 2023-05-30 19:55:31 +10:00
e33f31c483 Merge pull request #165 from doug-andrade/fix-typos
typo: /ressources to /resources
2023-05-30 19:54:32 +10:00
fe82e3c84f Merge pull request #171 from leerob/turbo
Add turborepo to monorepo.
2023-05-30 19:34:53 +10:00
7684a49b7d Merge branch 'main' into bugfix/long_filename 2023-05-30 14:30:55 +05:30
d8ad4b4b2b fix: add turbo dep and start command 2023-05-30 18:56:41 +10:00
e40ebd84d4 Merge pull request #174 from doug-andrade/fonts
loading fonts with next/font for no layout shift
2023-05-30 18:48:12 +10:00
a340b9c481 Merge pull request #173 from piyushkrmaurya/prisma_deprecations
fix: deprecated type checks and imports from @prisma/client
2023-05-30 18:35:32 +10:00
307b0cc9d9 fixed height on login/signup pages 2023-05-30 02:36:43 -04:00
3e94491474 fixed next/font load on ALL pages and toast. 2023-05-30 02:17:34 -04:00
007fe44db8 loading fonts with next/font 2023-05-29 22:25:36 -04:00
1e6f65f92d Explicit deps 2023-05-29 19:46:24 -05:00
82fbedf8e3 fix: deprecated type checks and imports from @prisma 2023-05-30 00:24:18 +05:30
2f3be1cfe5 Add turborepo to monorepo. 2023-05-29 10:38:24 -05:00
8ecd5cf215 fix: respect truncate title prop 2023-05-29 18:47:54 +10:00
f5091dd4d7 Merge pull request #166 from doug-andrade/tailwind
update: add new brand color as palette in tailwind config
2023-05-29 18:40:09 +10:00
4c06b5e640 fix: logo only on dashboard 2023-05-29 08:01:12 +00:00
b477799d70 update: add new brand color as palette in tailwind config 2023-05-28 23:17:52 -04:00
b928993510 typo: /ressources >> /resources 2023-05-28 20:06:43 -04:00
ad4d844b4d remove: next-transpile-modules dependency 2023-05-28 19:53:44 -04:00
3444d7fd93 task: simplify next.config.js 2023-05-28 19:44:00 -04:00
3e220135be fix: readme format 2023-05-28 17:42:54 +02:00
095c391d45 Merge pull request #163 from documenso/ElTimuro-patch-1
We are LIVE on Product Hunt 🚨 Come say hi :)
2023-05-28 10:16:35 +02:00
b0e4fa9e1d We are LIVE on Product Hunt 🚨 Come say hi :)
We are LIVE on Product Hunt 🚨 Come say hi :) 
https://www.producthunt.com/posts/documenso
2023-05-28 10:16:22 +02:00
f6bff1649b fix: disable empty state add doc without subscr. 2023-05-28 06:38:55 +02:00
b2b499f397 chore: update signup link 2023-05-28 06:16:48 +02:00
eb18a7e11c feat: update password in dashboard 2023-05-28 13:11:09 +10:00
89d9e02464 fix: update logo 2023-05-28 13:10:09 +10:00
a83b09f4db fix: update favicon 2023-05-28 13:07:47 +10:00
e445830ffb fix: convert readFile to buffer 2023-05-28 08:04:16 +10:00
bfff81dd3c fix: apply encoding on buffer level 2023-05-28 07:43:02 +10:00
02129aab73 Merge pull request #162 from documenso/feature/update-branding
Feature/update branding
2023-05-27 18:23:22 +02:00
e7386928fa Merge branch 'main' into feature/update-branding 2023-05-27 18:22:13 +02:00
7890b4adf1 feat: update logo 2023-05-27 18:18:34 +02:00
6aa40b2547 feat: update logo 2023-05-27 18:18:21 +02:00
c142c1bd54 feat: favicon logo 2023-05-27 18:11:49 +02:00
980bd0d485 fix: convert readFile to buffer 2023-05-28 00:53:43 +10:00
17d51354d7 fix: support cert file encodings 2023-05-28 00:39:07 +10:00
0881abdee4 Merge pull request #159 from documenso/feat/support-custom-cert-paths
feat: support leading cert from custom path
2023-05-27 23:39:08 +10:00
748f842115 fix: update prop typo, extract truncate method 2023-05-25 18:43:37 +10:00
4a2162478e Merge branch 'main' into bugfix/long_filename 2023-05-19 19:33:22 +02:00
1a5948c50e Merge branch 'bugfix/long_filename' of https://github.com/The-Robin-Hood/documenso into bugfix/long_filename 2023-05-15 15:43:32 +05:30
8bf6594cf4 prop name changed 📛 2023-05-15 15:43:29 +05:30
b6ff01ef86 Merge branch 'main' into bugfix/long_filename 2023-05-15 15:40:04 +05:30
9b993c08f1 Truncate prop added 2023-05-15 15:39:37 +05:30
051e681701 Long filename fix 🗄 2023-05-10 20:13:07 +05:30
446 changed files with 20501 additions and 26814 deletions

View File

@ -1,19 +1,17 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
**/node_modules
**/.pnp
**.pnp.js
node_modules
.pnp
.pnp.js
# testing
**/coverage
coverage
# next.js
**/.next/
**/out/
# production
**/build
.next/
out/
build
# misc
.DS_Store
@ -23,16 +21,16 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# turbo
.turbo
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env
.env.example

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2

View File

@ -1,53 +1,19 @@
# Database
# Option 1: You can use the provided remote test database, courtesy of the documenso team: postgres://documenso_test_user:GnmLG14u12sd9zHsd4vVWwP40WneFJMo@dpg-cf2hljh4reb5o45oqpq0-a.oregon-postgres.render.com/documenso_test_e2i3
# Option 2: Set up a local Postgres SQL instance (RECOMMENDED)
# Option 3: Use the provided dx setup (RECOMMENDED)
# => postgres://documenso:password@127.0.0.1:54320/documenso
#
# ⚠ WARNING: The test database can be resetted or taken offline at any point.
# ⚠ WARNING: Please be aware that nothing written to the test databae is private.
DATABASE_URL=''
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="secret"
# URL
NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000'
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# AUTH
# For more see here: https://next-auth.js.org
NEXTAUTH_SECRET='lorem ipsum sit dolor random string for encryption this could literally be anything'
NEXTAUTH_URL='http://localhost:3000'
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# SIGNING
CERT_FILE_PATH=
CERT_PASSPHRASE=
# MAIL (NODEMAILER)
# SENDGRID
# Get a Sendgrid Api key here: https://signup.sendgrid.com
SENDGRID_API_KEY=''
# SMTP
# Set SMTP credentials to use SMTP instead of the Sendgrid API.
# If you're using the dx setup you can use the following values:
#
# SMTP_MAIL_HOST='127.0.0.1'
# SMTP_MAIL_PORT='2500'
# SMTP_MAIL_USER='documenso'
# SMTP_MAIL_PASSWORD='documenso'
SMTP_MAIL_HOST=''
SMTP_MAIL_PORT=''
SMTP_MAIL_USER=''
SMTP_MAIL_PASSWORD=''
# Sender for signing requests and completion mails.
MAIL_FROM='documenso@localhost.com'
# STRIPE
STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
#FEATURE FLAGS
# Allow users to register via the /signup page. Otherwise they will be redirect to the home page.
NEXT_PUBLIC_ALLOW_SIGNUP=true
NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS=true
NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false
# This is only required for the marketing site
NEXT_PRIVATE_REDIS_URL=
NEXT_PRIVATE_REDIS_TOKEN=

13
.eslintrc.cjs Normal file
View File

@ -0,0 +1,13 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
extends: ['@documenso/eslint-config'],
rules: {
'@next/next/no-img-element': 'off',
},
settings: {
next: {
rootDir: ['apps/*/'],
},
},
};

30
.gitignore vendored
View File

@ -1,19 +1,17 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
node_modules
.pnp
.pnp.js
# testing
/coverage
coverage
# next.js
/.next/
/out/
# production
/build
.next/
out/
build
# misc
.DS_Store
@ -23,16 +21,16 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# turbo
.turbo
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env
.env.example

1
.npmrc Normal file
View File

@ -0,0 +1 @@
auto-install-peers = true

29
.vscode/settings.json vendored
View File

@ -1,25 +1,10 @@
{
"files.autoSave": "afterDelay",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.formatOnType": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[html]": {
"editor.defaultFormatter": "vscode.html-language-features"
},
"[prisma]": {
"editor.defaultFormatter": "Prisma.prisma"
},
"editor.codeActionsOnSave": {
"source.removeUnusedImports": false
},
"typescript.tsdk": "node_modules/typescript/lib",
"spellright.language": [
"de"
],
"spellright.documentTypes": [
"markdown",
"latex",
"plaintext"
]
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
"javascript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.useAliasesForRenames": false,
"typescript.enablePromptUseWorkspaceTsdk": true
}

View File

@ -1,7 +1,4 @@
> <strong>We are launching TOMORROW on Product Hunt soon! Sign up to support the launch: </strong>
> <center><a href="https://dub.sh/documenso-launch"><img src="https://img.shields.io/badge/Documenso%20on%20Product%20Hunt-Notify%20Me-orange" alt="Product Hunt"></a></center>
<p align="center" style="margin-top: 12px">
<p align="center" style="margin-top: 120px">
<a href="https://github.com/documenso/documenso.com">
<img width="250px" src="https://github.com/documenso/documenso/assets/1309312/cd7823ec-4baa-40b9-be78-4acb3b1c73cb" alt="Documenso Logo">
</a>
@ -14,7 +11,7 @@
<a href="https://documenso.com"><strong>Learn more »</strong></a>
<br />
<br />
<a href="https://join.slack.com/t/documenso/shared_invite/zt-1qwxxsvli-nDyojjt~wakhgBGl9JRl2w">Slack</a>
<a href="https://documen.so/slack">Slack</a>
·
<a href="https://documenso.com">Website</a>
·
@ -25,7 +22,7 @@
</p>
<p align="center">
<a href="https://join.slack.com/t/documenso/shared_invite/zt-1qwxxsvli-nDyojjt~wakhgBGl9JRl2w"><img src="https://img.shields.io/badge/Slack-documenso.slack.com-%234A154B" alt="Join Documenso on Slack"></a>
<a href="https://documen.so/slack"><img src="https://img.shields.io/badge/Slack-documenso.slack.com-%234A154B" alt="Join Documenso on Slack"></a>
<a href="https://github.com/documenso/documenso/stargazers"><img src="https://img.shields.io/github/stars/documenso/documenso" alt="Github Stars"></a>
<a href="https://github.com/documenso/documenso/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
<a href="https://github.com/documenso/documenso/pulse"><img src="https://img.shields.io/github/commit-activity/m/documenso/documenso" alt="Commits-per-month"></a>
@ -59,13 +56,18 @@
Signing documents digitally is fast, easy and should be best practice for every document signed worldwide. This is technically quite easy today, but it also introduces a new party to every signature: The signing tool providers. While this is not a problem in itself, it should make us think about how we want these providers of trust to work. Documenso aims to be the world's most trusted document signing tool. This trust is built by empowering you to self-host Documenso and review how it works under the hood. Join us in creating the next generation of open trust infrastructure.
## Recognition
<a 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&#0032;open&#0032;source&#0032;DocuSign&#0032;alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/posts/documenso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-documenso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=395047&theme=light" alt="Documenso - The&#0032;Open&#0032;Source&#0032;DocuSign&#0032;Alternative&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
## Community and Next Steps 🎯
The current project goal is to <b>[release a production ready version](https://github.com/documenso/documenso/milestone/1)</b> for self-hosting as soon as possible. If you want to help making that happen you can:
- Check out the first source code release in this repository and test it
- Tell us what you think in the current [Discussions](https://github.com/documenso/documenso/discussions)
- Join the [Slack Channel](https://join.slack.com/t/documenso/shared_invite/zt-1qwxxsvli-nDyojjt~wakhgBGl9JRl2w) for any questions and getting to know to other community members
- Join the [Slack Channel](https://documen.so/slack) for any questions and getting to know to other community members
- ⭐ the repository to help us raise awareness
- Spread the word on Twitter, that Documenso is working towards a more open signing tool
- Fix or create [issues](https://github.com/documenso/documenso/issues), that are needed for the first production release
@ -74,8 +76,6 @@ The current project goal is to <b>[release a production ready version](https://g
- To contribute please see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md).
# Tech
Documenso is built using awesome open source tech including:
@ -119,7 +119,7 @@ Want to get up and running quickly? Follow these steps:
- This will spin up a postgres database and inbucket mail server in docker containers.
- Run `npm run dev` in the root directory
- Want it even faster? Just use
```sh
```sh
npm run d
```
@ -153,10 +153,10 @@ Follow these steps to setup documenso on you local machine:
---
- Optional: Seed the database using <code>npm run db-seed</code> to create a test user and document
- Optional: Upload and sign <code>apps/web/ressources/example.pdf</code> manually to test your setup
- Optional: Upload and sign <code>apps/web/resources/example.pdf</code> manually to test your setup
- Optional: Create your own signing certificate
- A demo certificate is provided in `/app/web/ressources/certificate.p12`
- A demo certificate is provided in `/app/web/resources/certificate.p12`
- To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL) see **[Create your own signing certificate](#creating-your-own-signing-certificate)**.
## Updating
@ -181,7 +181,7 @@ For the digital signature of your documents you need a signing certificate in .p
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following command to do this: \
<code>openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt</code>
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
5. Place the certificate <code>/apps/web/ressource/certificate.p12</code>
5. Place the certificate <code>/apps/web/resources/certificate.p12</code>
# Docker
@ -196,3 +196,32 @@ Want to create a production ready docker image? Follow these steps:
- Docker support
- One-Click-Deploy on Render.com Deploy
# Troubleshooting
## Support IPv6
In case you are deploying to a cluster that uses only IPv6. You can use a custom command to pass a parameter to the NextJS start command
For local docker run
```bash
docker run -it documenso:latest npm run start -- -H ::
```
For k8s or docker-compose
```yaml
containers:
- name: documenso
image: documenso:latest
imagePullPolicy: IfNotPresent
command:
- npm
args:
- run
- start
- --
- -H
- "::"
```

1
apps/marketing/README.md Normal file
View File

@ -0,0 +1 @@
# @documenso/marketing

6
apps/marketing/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -0,0 +1,15 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path');
const { parsed: env } = require('dotenv').config({
path: path.join(__dirname, '../../.env.local'),
});
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
env,
};
module.exports = config;

View File

@ -0,0 +1,37 @@
{
"name": "@documenso/marketing",
"version": "0.1.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
"dev": "PORT=3001 next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@documenso/lib": "*",
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
"framer-motion": "^10.12.8",
"lucide-react": "^0.214.0",
"micro": "^10.0.1",
"next": "13.4.1",
"next-auth": "^4.22.1",
"next-plausible": "^3.7.2",
"perfect-freehand": "^1.2.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.43.9",
"react-icons": "^4.8.0",
"typescript": "5.0.4",
"zod": "^3.21.4"
},
"devDependencies": {
"@types/node": "20.1.0",
"@types/react": "18.2.6",
"@types/react-dom": "18.2.4"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

13
apps/marketing/process-env.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
declare namespace NodeJS {
export interface ProcessEnv {
NEXT_PUBLIC_SITE_URL?: string;
NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 KiB

View File

@ -0,0 +1,19 @@
{
"name": "Documenso",
"short_name": "Documenso",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#A2E771",
"background_color": "#FFFFFF",
"display": "standalone"
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@ -0,0 +1,41 @@
import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types';
export const claimPlan = async ({
name,
email,
planId,
signatureDataUrl,
signatureText,
}: TClaimPlanRequestSchema) => {
const response = await fetch('/api/claim-plan', {
method: 'POST',
body: JSON.stringify({
name,
email,
planId,
signatureDataUrl,
signatureText,
}),
headers: {
'Content-Type': 'application/json',
},
});
const body = await response.json();
if (response.status !== 200) {
throw new Error('Failed to claim plan');
}
const safeBody = ZClaimPlanResponseSchema.safeParse(body);
if (!safeBody.success) {
throw new Error('Failed to claim plan');
}
if ('error' in safeBody.data) {
throw new Error(safeBody.data.error);
}
return safeBody.data.redirectUrl;
};

View File

@ -0,0 +1,37 @@
import { z } from 'zod';
export const ZClaimPlanRequestSchema = z
.object({
email: z
.string()
.email()
.transform((value) => value.toLowerCase()),
name: z.string(),
planId: z.string(),
})
.and(
z.union([
z.object({
signatureDataUrl: z.string().min(1),
signatureText: z.null(),
}),
z.object({
signatureDataUrl: z.null(),
signatureText: z.string().min(1),
}),
]),
);
export type TClaimPlanRequestSchema = z.infer<typeof ZClaimPlanRequestSchema>;
export const ZClaimPlanResponseSchema = z
.object({
redirectUrl: z.string(),
})
.or(
z.object({
error: z.string(),
}),
);
export type TClaimPlanResponseSchema = z.infer<typeof ZClaimPlanResponseSchema>;

View File

@ -0,0 +1,173 @@
import { Caveat } from 'next/font/google';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ArrowRight } from 'lucide-react';
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { PasswordReveal } from '~/components/(marketing)/password-reveal';
const fontCaveat = Caveat({
weight: ['500'],
subsets: ['latin'],
display: 'swap',
});
export type ClaimedPlanPageProps = {
searchParams?: {
sessionId?: string;
};
};
export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlanPageProps) {
const { sessionId } = searchParams;
const session = await stripe.checkout.sessions.retrieve(sessionId as string);
const user = await prisma.user.findFirst({
where: {
id: Number(session.client_reference_id),
},
});
if (!user) {
redirect('/');
}
const signatureText = session.metadata?.signatureText || user.name;
let signatureDataUrl = '';
if (session.metadata?.signatureDataUrl) {
const result = await redis.get<string>(`signature:${session.metadata.signatureDataUrl}`);
if (result) {
signatureDataUrl = result;
}
}
const password = await redis.get<string>(`user:${user.id}:temp-password`);
return (
<div className="mt-12">
<h1 className="text-3xl font-bold text-slate-900 md:text-4xl">
Welcome to the <span className="text-primary">open signing</span> revolution{' '}
<u>{user.name}</u>
</h1>
<p className="mt-4 max-w-prose text-base text-slate-500 md:text-lg">
It's not every day you get to be part of a revolution.
</p>
<p className="mt-4 max-w-prose text-base text-slate-500 md:text-lg">
But today is that day, by signing up to Documenso, you're joining a movement of people who
want to make the world a better place.
</p>
<p className="mt-4 max-w-prose text-base text-slate-500 md:text-lg">
We're going to change the way people sign documents. We're going to make it easier, faster,
and more secure. And we're going to do it together.
</p>
<div className="mt-12">
<h2 className="text-2xl font-bold text-slate-900">Let's do it together</h2>
<div className="-mx-4 mt-8 flex md:-mx-8">
<div className="flex flex-1 flex-col justify-end gap-y-4 border-r px-4 last:border-r-0 md:px-8 lg:flex-none">
<p
className={cn(
'text-4xl font-semibold text-slate-900 md:text-5xl',
fontCaveat.className,
)}
>
Timur
</p>
<p className="text-sm text-slate-500 md:text-lg">
Timur Ercan
<span className="block lg:hidden" />
<span className="hidden lg:inline"> - </span>
Co Founder
</p>
</div>
<div className="flex flex-1 flex-col justify-end gap-y-4 border-r px-4 last:border-r-0 md:px-8 lg:flex-none">
<p
className={cn(
'text-4xl font-semibold text-slate-900 md:text-5xl',
fontCaveat.className,
)}
>
Lucas
</p>
<p className="text-sm text-slate-500 md:text-lg">
Lucas Smith
<span className="block lg:hidden" />
<span className="hidden lg:inline"> - </span>
Co Founder
</p>
</div>
<div className="flex flex-1 flex-col justify-end gap-y-4 border-r px-4 last:border-r-0 md:px-8 lg:flex-none">
{signatureDataUrl && (
<img src={signatureDataUrl} alt="your-signature" className="max-w-[172px]" />
)}
{!signatureDataUrl && (
<p
className={cn(
'text-4xl font-semibold text-slate-900 md:text-5xl',
fontCaveat.className,
)}
>
{signatureText}
</p>
)}
<p className="text-sm text-slate-500 md:text-lg">
{user.name}
<span className="block lg:hidden" />
<span className="hidden lg:inline"> - </span>
Our new favourite customer
</p>
</div>
</div>
</div>
<div className="mt-12">
<h2 className="text-2xl font-bold text-slate-900">Your sign in details</h2>
<div className="mt-4">
<p className="text-lg text-slate-500">
<span className="font-bold">Email:</span> {user.email}
</p>
<p className="mt-2 text-lg text-slate-500">
<span className="font-bold">Password:</span>{' '}
<PasswordReveal password={password ?? 'password'} />
</p>
</div>
<p className="mt-4 text-sm italic text-slate-500">
This is a temporary password. Please change it as soon as possible.
</p>
<Link
// eslint-disable-next-line turbo/no-undeclared-env-vars
href={`${process.env.NEXT_PUBLIC_APP_URL}/login`}
target="_blank"
className="mt-4 block"
>
<Button size="lg" className="text-base">
Let's get started!
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
import React from 'react';
import { Footer } from '~/components/(marketing)/footer';
import { Header } from '~/components/(marketing)/header';
export type MarketingLayoutProps = {
children: React.ReactNode;
};
export default function MarketingLayout({ children }: MarketingLayoutProps) {
return (
<div className="relative max-w-[100vw] overflow-y-auto overflow-x-hidden pt-20 md:pt-28">
<div className="fixed left-0 top-0 z-50 w-full bg-white/50 backdrop-blur-md">
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div>
<div className="relative mx-auto max-w-screen-xl px-4 lg:px-8">{children}</div>
<Footer className="mt-24 bg-transparent backdrop-blur-[2px]" />
</div>
);
}

View File

@ -0,0 +1,31 @@
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
import { Caveat } from 'next/font/google';
import { cn } from '@documenso/ui/lib/utils';
import { Callout } from '~/components/(marketing)/callout';
import { FasterSmarterBeautifulBento } from '~/components/(marketing)/faster-smarter-beautiful-bento';
import { Hero } from '~/components/(marketing)/hero';
import { OpenBuildTemplateBento } from '~/components/(marketing)/open-build-template-bento';
import { ShareConnectPaidWidgetBento } from '~/components/(marketing)/share-connect-paid-widget-bento';
const fontCaveat = Caveat({
weight: ['500'],
subsets: ['latin'],
display: 'swap',
variable: '--font-caveat',
});
export default async function IndexPage() {
return (
<div className={cn('mt-12', fontCaveat.variable)}>
<Hero />
<FasterSmarterBeautifulBento className="my-48" />
<ShareConnectPaidWidgetBento className="my-48" />
<OpenBuildTemplateBento className="my-48" />
<Callout />
</div>
);
}

View File

@ -0,0 +1,163 @@
import Link from 'next/link';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import { PricingTable } from '~/components/(marketing)/pricing-table';
export type PricingPageProps = {
searchParams?: {
planId?: string;
email?: string;
name?: string;
cancelled?: string;
};
};
export default function PricingPage() {
return (
<div className="mt-12">
<div className="text-center">
<h1 className="text-3xl font-bold lg:text-5xl">Pricing</h1>
<p className="mt-4 text-lg leading-normal text-[#31373D]">
Designed for every stage of your journey.
</p>
<p className="text-lg leading-normal text-[#31373D]">Get started today.</p>
</div>
<div className="mt-12">
<PricingTable />
</div>
<div className="mx-auto mt-36 max-w-2xl">
{/* FAQ Section */}
<h2 className="text-4xl font-semibold">FAQs</h2>
<Accordion type="multiple" className="mt-8">
<AccordionItem value="plan-differences">
<AccordionTrigger className="text-left text-lg font-semibold">
What is the difference between the plans?
</AccordionTrigger>
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
You can self-host Documenso for free or use our ready-to-use hosted version. The
hosted version comes with additional support, painless scalability and more. Early
adopters of the community plan will get access to all features we build this year, for
no additional cost! Forever! Yes, that includes multiple users per account later. If
you want Documenso for your enterprise, we are happy to talk about your needs.
</AccordionContent>
</AccordionItem>
<AccordionItem value="data-handling">
<AccordionTrigger className="text-left text-lg font-semibold">
How do you handle my data?
</AccordionTrigger>
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
Securely. Our data centers are located in Frankfurt (Germany), giving us the best
local privacy laws. We are very aware of the sensitive nature of our data and follow
best practices to ensure the security and integrity of the data entrusted to us.
</AccordionContent>
</AccordionItem>
<AccordionItem value="should-use-cloud">
<AccordionTrigger className="text-left text-lg font-semibold">
Why should I use your hosting service?
</AccordionTrigger>
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
Using our hosted version is the easiest way to get started, you can simply subscribe
and start signing your documents. We take care of the infrastructure, so you can focus
on your business. Additionally, when using our hosted version you benefit from our
trusted signing certificates which helps you to build trust with your customers.
</AccordionContent>
</AccordionItem>
<AccordionItem value="how-to-contribute">
<AccordionTrigger className="text-left text-lg font-semibold">
How can I contribute?
</AccordionTrigger>
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
That's awesome. You can take a look at the current{' '}
<Link
className="text-documenso-700 font-bold"
href="https://github.com/documenso/documenso/milestones"
target="_blank"
>
Issues
</Link>{' '}
and join our{' '}
<Link
className="text-documenso-700 font-bold"
href="https://join.slack.com/t/documenso/shared_invite/zt-1vibm8txi-DqsDFtdp44Hn2H5lc~RpPQ"
target="_blank"
>
Slack Community
</Link>{' '}
to keep up to date, on what the current priorities are. In any case, we are an open
community and welcome all input, technical and non-technical ❤️
</AccordionContent>
</AccordionItem>
<AccordionItem value="can-i-use-documenso-commercially">
<AccordionTrigger className="text-left text-lg font-semibold">
Can I use Documenso commercially?
</AccordionTrigger>
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
Yes! Documenso is offered under the GNU AGPL V3 open source license. This means you
can use it for free and even modify it to fit your needs, as long as you publish your
changes under the same license.
</AccordionContent>
</AccordionItem>
<AccordionItem value="why-prefer-documenso">
<AccordionTrigger className="text-left text-lg font-semibold">
Why should I prefer Documenso over DocuSign or some other signing tool?
</AccordionTrigger>
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
Documenso is a community effort to create an open and vibrant ecosystem around a tool,
everybody is free to use and adapt. By being truly open we want to create trusted
infrastructure for the future of the internet.
</AccordionContent>
</AccordionItem>
<AccordionItem value="where-can-i-get-support">
<AccordionTrigger className="text-left text-lg font-semibold">
Where can I get support?
</AccordionTrigger>
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
We are happy to assist you at{' '}
<Link
className="text-documenso-700 font-bold"
target="_blank"
href="mailto:support@documenso.com"
>
support@documenso.com
</Link>{' '}
or{' '}
<a
className="text-documenso-700 font-bold"
href="https://join.slack.com/t/documenso/shared_invite/zt-1vibm8txi-DqsDFtdp44Hn2H5lc~RpPQ"
target="_blank"
>
in our Slack-Support-Channel
</a>{' '}
please message either Lucas or Timur to get added to the channel if you are not
already a member.
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
);
}

View File

@ -0,0 +1 @@
@import '@documenso/ui/styles/theme.css';

View File

@ -0,0 +1,53 @@
import { Inter } from 'next/font/google';
import { Toaster } from '@documenso/ui/primitives/toaster';
import { PlausibleProvider } from '~/providers/plausible';
import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
export const metadata = {
title: 'Documenso - The Open Source DocuSign Alternative',
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' },
robots: 'index, follow',
openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative',
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
type: 'website',
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`],
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={fontInter.variable} suppressHydrationWarning>
<head>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
</head>
<body>
<PlausibleProvider>
{children}
</PlausibleProvider>
<Toaster />
</body>
</html>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

View File

@ -0,0 +1,56 @@
'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 const Callout = () => {
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 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
</Button>
</Link>
</div>
);
};

View File

@ -0,0 +1,148 @@
'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>
);
};

View File

@ -0,0 +1,77 @@
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. Thats 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>
);
};

View File

@ -0,0 +1,86 @@
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>
);
};

View File

@ -0,0 +1,32 @@
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>
);
};

View File

@ -0,0 +1,217 @@
'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;
[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, ...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 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
</Button>
</Link>
</motion.div>
<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>
<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>
);
};

View File

@ -0,0 +1,74 @@
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>
Its 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>
);
};

View File

@ -0,0 +1,33 @@
'use client';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
export type PasswordRevealProps = {
password: string;
};
export const PasswordReveal = ({ password }: PasswordRevealProps) => {
const { toast } = useToast();
const [, copy] = useCopyToClipboard();
const onCopyClick = () => {
copy(password).then(() => {
toast({
title: 'Copied to clipboard',
description: 'Your password has been copied to your clipboard.',
});
});
};
return (
<button
type="button"
className="px-2 blur-sm hover:opacity-50 hover:blur-none"
onClick={onCopyClick}
>
{password}
</button>
);
};

View File

@ -0,0 +1,179 @@
'use client';
import { HTMLAttributes, useMemo, useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { AnimatePresence, motion } from 'framer-motion';
import { usePlausible } from 'next-plausible';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { ClaimPlanDialog } from './claim-plan-dialog';
export type PricingTableProps = HTMLAttributes<HTMLDivElement>;
const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar';
export const PricingTable = ({ className, ...props }: PricingTableProps) => {
const params = useSearchParams();
const event = usePlausible();
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() =>
// eslint-disable-next-line turbo/no-undeclared-env-vars
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
? 'YEARLY'
: 'MONTHLY',
);
const planId = useMemo(() => {
if (period === 'MONTHLY') {
// eslint-disable-next-line turbo/no-undeclared-env-vars
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
}
// eslint-disable-next-line turbo/no-undeclared-env-vars
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID;
}, [period]);
return (
<div className={cn('', className)} {...props}>
<div className="flex items-center justify-center gap-x-6">
<AnimatePresence>
<motion.button
key="MONTHLY"
className={cn('relative flex items-center gap-x-2.5 px-1 py-2.5 text-[#727272]', {
'text-slate-900': period === 'MONTHLY',
'hover:text-slate-900/80': period !== 'MONTHLY',
})}
onClick={() => setPeriod('MONTHLY')}
>
Monthly
{period === 'MONTHLY' && (
<motion.div
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
/>
)}
</motion.button>
<motion.button
key="YEARLY"
className={cn('relative flex items-center gap-x-2.5 px-1 py-2.5 text-[#727272]', {
'text-slate-900': period === 'YEARLY',
'hover:text-slate-900/80': period !== 'YEARLY',
})}
onClick={() => setPeriod('YEARLY')}
>
Yearly
<div className="block rounded-full bg-slate-200 px-2 py-0.5 text-xs text-slate-700">
Save $60
</div>
{period === 'YEARLY' && (
<motion.div
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
/>
)}
</motion.button>
</AnimatePresence>
</div>
<div className="mt-12 grid grid-cols-1 gap-x-6 gap-y-12 md:grid-cols-2 lg:grid-cols-3">
<div
data-plan="self-hosted"
className="flex flex-col items-center justify-center rounded-lg border bg-white px-8 py-12 shadow-lg shadow-slate-900/5"
>
<p className="text-4xl font-medium text-slate-900">Self Hosted</p>
<p className="text-primary mt-2.5 text-xl font-medium">Free</p>
<p className="mt-4 max-w-[30ch] text-center text-slate-900">
For small teams and individuals who need a simple solution
</p>
<Link
href="https://github.com/documenso/documenso"
target="_blank"
className="mt-6"
onClick={() => event('view-github')}
>
<Button className="rounded-full text-base">View on Github</Button>
</Link>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="py-4 font-medium text-slate-900">Host your own instance</p>
<p className="py-4 text-slate-900">Full Control</p>
<p className="py-4 text-slate-900">Customizability</p>
<p className="py-4 text-slate-900">Docker Ready</p>
<p className="py-4 text-slate-900">Community Support</p>
<p className="py-4 text-slate-900">Free, Forever</p>
</div>
</div>
<div
data-plan="community"
className="border-primary flex flex-col items-center justify-center rounded-lg border-2 bg-white px-8 py-12 shadow-[0px_0px_0px_4px_#E3E3E380] shadow-slate-900/5"
>
<p className="text-4xl font-medium text-slate-900">Community</p>
<div className="text-primary mt-2.5 text-xl font-medium">
<AnimatePresence mode="wait">
{period === 'MONTHLY' && <motion.div layoutId="pricing">$30</motion.div>}
{period === 'YEARLY' && <motion.div layoutId="pricing">$300</motion.div>}
</AnimatePresence>
</div>
<p className="mt-4 max-w-[30ch] text-center text-slate-900">
For fast-growing companies that aim to scale across multiple teams.
</p>
<ClaimPlanDialog planId={planId}>
<Button className="mt-6 rounded-full text-base">Signup Now</Button>
</ClaimPlanDialog>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="py-4 font-medium text-slate-900">Documenso Early Adopter Deal:</p>
<p className="py-4 text-slate-900">Join the movement</p>
<p className="py-4 text-slate-900">Simple signing solution</p>
<p className="py-4 text-slate-900">Email and Slack assistance</p>
<p className="py-4 text-slate-900">
<strong>Includes all upcoming features</strong>
</p>
<p className="py-4 text-slate-900">Fixed, straightforward pricing</p>
</div>
</div>
<div
data-plan="enterprise"
className="flex flex-col items-center justify-center rounded-lg border bg-white px-8 py-12 shadow-lg shadow-slate-900/5"
>
<p className="text-4xl font-medium text-slate-900">Enterprise</p>
<p className="text-primary mt-2.5 text-xl font-medium">Pricing on request</p>
<p className="mt-4 max-w-[30ch] text-center text-slate-900">
For large organizations that need extra flexibility and control.
</p>
<Link
href="https://dub.sh/enterprise"
target="_blank"
className="mt-6"
onClick={() => event('enterprise-contact')}
>
<Button className="rounded-full text-base">Contact Us</Button>
</Link>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="py-4 font-medium text-slate-900">Everything in Community, plus:</p>
<p className="py-4 text-slate-900">Custom Subdomain</p>
<p className="py-4 text-slate-900">Compliance Check</p>
<p className="py-4 text-slate-900">Guaranteed Uptime</p>
<p className="py-4 text-slate-900">Reporting & Analysis</p>
<p className="py-4 text-slate-900">24/7 Support</p>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,91 @@
import { HTMLAttributes } from 'react';
import Image from 'next/image';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import backgroundPattern from '~/assets/background-pattern.png';
import cardConnectionsFigure from '~/assets/card-connections-figure.png';
import cardPaidFigure from '~/assets/card-paid-figure.png';
import cardSharingFigure from '~/assets/card-sharing-figure.png';
import cardWidgetFigure from '~/assets/card-widget-figure.png';
export type ShareConnectPaidWidgetBentoProps = HTMLAttributes<HTMLDivElement>;
export const ShareConnectPaidWidgetBento = ({
className,
...props
}: ShareConnectPaidWidgetBentoProps) => {
return (
<div className={cn('relative', className)} {...props}>
<div className="absolute inset-0 -z-10 flex items-center justify-center">
<Image
src={backgroundPattern}
alt="background pattern"
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
/>
</div>
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
Integrates with all your favourite tools.
<span className="block md:mt-0">Send, connect, receive and embed everywhere.</span>
</h2>
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
<Card className="col-span-2 lg:col-span-1" degrees={120} gradient>
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="leading-relaxed text-[#555E67]">
<strong className="block">Easy Sharing (Soon).</strong>
Receive your personal link to share with everyone you care about.
</p>
<div className="flex items-center justify-center p-8">
<Image src={cardSharingFigure} alt="its fast" className="w-full max-w-xs" />
</div>
</CardContent>
</Card>
<Card className="col-span-2 lg:col-span-1" spotlight>
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="leading-relaxed text-[#555E67]">
<strong className="block">Connections (Soon).</strong>
Create connections and automations with Zapier and more to integrate with your
favorite tools.
</p>
<div className="flex items-center justify-center p-8">
<Image src={cardConnectionsFigure} alt="its fast" className="w-full max-w-sm" />
</div>
</CardContent>
</Card>
<Card className="col-span-2 lg:col-span-1" spotlight>
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="leading-relaxed text-[#555E67]">
<strong className="block">Get paid (Soon).</strong>
Integrated payments with stripe so you dont have to worry about getting paid.
</p>
<div className="flex items-center justify-center p-8">
<Image src={cardPaidFigure} alt="its fast" className="w-full max-w-[14rem]" />
</div>
</CardContent>
</Card>
<Card className="col-span-2 lg:col-span-1" spotlight>
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="leading-relaxed text-[#555E67]">
<strong className="block">React Widget (Soon).</strong>
Easily embed Documenso into your product. Simply copy and paste our react widget into
your application.
</p>
<div className="flex items-center justify-center p-8">
<Image src={cardWidgetFigure} alt="its fast" className="w-full max-w-xs" />
</div>
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -0,0 +1,400 @@
'use client';
import { HTMLAttributes, KeyboardEvent, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { claimPlan } from '~/api/claim-plan/fetcher';
import { FormErrorMessage } from '../form/form-error-message';
import { SignaturePad } from '../signature-pad';
const ZWidgetFormSchema = z
.object({
email: z.string().email({ message: 'Please enter a valid email address.' }),
name: z.string().min(3, { message: 'Please enter a valid name.' }),
})
.and(
z.union([
z.object({
signatureDataUrl: z.string().min(1),
signatureText: z.null().or(z.string().max(0)),
}),
z.object({
signatureDataUrl: z.null().or(z.string().max(0)),
signatureText: z.string().min(1),
}),
]),
);
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
export type WidgetProps = HTMLAttributes<HTMLDivElement>;
export const Widget = ({ className, children, ...props }: WidgetProps) => {
const { toast } = useToast();
const event = usePlausible();
const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL');
const [showSigningDialog, setShowSigningDialog] = useState(false);
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
const {
control,
register,
handleSubmit,
setValue,
trigger,
watch,
formState: { errors, isSubmitting, isValid },
} = useForm<TWidgetFormSchema>({
mode: 'onChange',
defaultValues: {
email: '',
name: '',
signatureDataUrl: null,
signatureText: '',
},
resolver: zodResolver(ZWidgetFormSchema),
});
const signatureDataUrl = watch('signatureDataUrl');
const signatureText = watch('signatureText');
const stepsRemaining = useMemo(() => {
if (step === 'NAME') {
return 2;
}
if (step === 'SIGN') {
return 1;
}
return 3;
}, [step]);
const onNextStepClick = () => {
if (step === 'EMAIL') {
setStep('NAME');
setTimeout(() => {
document.querySelector<HTMLElement>('#name')?.focus();
}, 0);
}
if (step === 'NAME') {
setStep('SIGN');
setTimeout(() => {
document.querySelector<HTMLElement>('#signatureText')?.focus();
}, 0);
}
};
const onEnterPress = (callback: () => void) => {
return (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
callback();
}
};
};
const onSignatureConfirmClick = () => {
setValue('signatureDataUrl', draftSignatureDataUrl);
setValue('signatureText', '');
trigger('signatureDataUrl');
setShowSigningDialog(false);
};
const onFormSubmit = async ({
email,
name,
signatureDataUrl,
signatureText,
}: TWidgetFormSchema) => {
try {
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
// eslint-disable-next-line turbo/no-undeclared-env-vars
const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
const claimPlanInput = signatureDataUrl
? {
name,
email,
planId,
signatureDataUrl: signatureDataUrl!,
signatureText: null,
}
: {
name,
email,
planId,
signatureDataUrl: null,
signatureText: signatureText!,
};
const [result] = await Promise.all([claimPlan(claimPlanInput), delay]);
event('claim-plan-widget');
window.location.href = result;
} catch (error) {
event('claim-plan-failed');
toast({
title: 'Something went wrong',
description: error instanceof Error ? error.message : 'Please try again later.',
variant: 'destructive',
});
}
};
return (
<>
<Card
className={cn('mx-auto w-full max-w-4xl rounded-3xl before:rounded-3xl', className)}
gradient
{...props}
>
<div className="grid grid-cols-12 gap-y-8 overflow-hidden p-2 lg:gap-x-8">
<div className="col-span-12 flex flex-col gap-y-4 p-4 text-xs leading-relaxed text-[#727272] lg:col-span-7">
{children}
</div>
<form
className="col-span-12 flex flex-col rounded-2xl bg-[#F7F7F7] p-6 lg:col-span-5"
onSubmit={handleSubmit(onFormSubmit)}
>
<h3 className="text-2xl font-semibold">Sign up for the community plan</h3>
<p className="mt-2 text-xs text-[#AFAFAF]">
with Timur Ercan & Lucas Smith from Documenso
</p>
<hr className="mb-6 mt-4" />
<AnimatePresence>
<motion.div key="email">
<label htmlFor="email" className="text-lg font-semibold text-slate-900 lg:text-xl">
Whats your email?
</label>
<Controller
control={control}
name="email"
render={({ field }) => (
<div className="relative mt-2">
<Input
id="email"
type="email"
placeholder=""
className="w-full bg-white pr-16"
disabled={isSubmitting}
onKeyDown={(e) =>
field.value !== '' &&
!errors.email?.message &&
onEnterPress(onNextStepClick)(e)
}
{...field}
/>
<div className="absolute inset-y-0 right-0 p-1.5">
<Button
type="button"
className="bg-primary h-full w-14 rounded"
disabled={!field.value || !!errors.email?.message}
onClick={() => onNextStepClick()}
>
Next
</Button>
</div>
</div>
)}
/>
<FormErrorMessage error={errors.email} className="mt-1" />
</motion.div>
{(step === 'NAME' || step === 'SIGN') && (
<motion.div
key="name"
className="mt-4"
animate={{
opacity: 1,
transform: 'translateX(0)',
}}
initial={{
opacity: 0,
transform: 'translateX(-25%)',
}}
exit={{
opacity: 0,
transform: 'translateX(25%)',
}}
>
<label htmlFor="name" className="text-lg font-semibold text-slate-900 lg:text-xl">
and your name?
</label>
<Controller
control={control}
name="name"
render={({ field }) => (
<div className="relative mt-2">
<Input
id="name"
type="text"
placeholder=""
className="w-full bg-white pr-16"
disabled={isSubmitting}
onKeyDown={(e) =>
field.value !== '' &&
!errors.name?.message &&
onEnterPress(onNextStepClick)(e)
}
{...field}
/>
<div className="absolute inset-y-0 right-0 p-1.5">
<Button
type="button"
className="bg-primary h-full w-14 rounded"
disabled={!field.value || !!errors.name?.message}
onClick={() => onNextStepClick()}
>
Next
</Button>
</div>
</div>
)}
/>
<FormErrorMessage error={errors.name} className="mt-1" />
</motion.div>
)}
</AnimatePresence>
<div className="mt-12 flex-1" />
<div className="flex items-center justify-between">
<p className="text-xs text-[#AFAFAF]">{stepsRemaining} step(s) until signed</p>
<p className="block text-xs text-[#AFAFAF] md:hidden">Minimise contract</p>
</div>
<div className="relative mt-2.5 h-[2px] w-full bg-[#E9E9E9]">
<div
className={cn('bg-primary/60 absolute inset-y-0 left-0 duration-200', {
'w-1/3': stepsRemaining === 3,
'w-2/3': stepsRemaining === 2,
'w-11/12': stepsRemaining === 1,
})}
/>
</div>
<Card id="signature" className="mt-4" degrees={-140} gradient>
<CardContent
role="button"
className="relative cursor-pointer pt-6"
onClick={() => setShowSigningDialog(true)}
>
<div className="flex h-28 items-center justify-center pb-6">
{!signatureText && signatureDataUrl && (
<img src={signatureDataUrl} alt="user signature" className="h-full" />
)}
{signatureText && (
<p
className={cn(
'text-4xl font-semibold text-slate-900 [font-family:var(--font-caveat)]',
)}
>
{signatureText}
</p>
)}
</div>
<div
className="absolute inset-x-0 bottom-0 flex cursor-auto items-center justify-between px-4 pb-2"
onClick={(e) => e.stopPropagation()}
>
<Input
id="signatureText"
className="border-none p-0 text-sm text-slate-700 placeholder:text-[#D6D6D6] focus-visible:ring-0"
placeholder="Draw or type name here"
disabled={isSubmitting}
{...register('signatureText', {
onChange: (e) => {
if (e.target.value !== '') {
setValue('signatureDataUrl', null);
}
},
})}
/>
<Button
type="submit"
className="h-8 disabled:bg-[#ECEEED] disabled:text-[#C6C6C6] disabled:hover:bg-[#ECEEED]"
disabled={!isValid || isSubmitting}
>
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
Sign
</Button>
</div>
</CardContent>
</Card>
</form>
</div>
</Card>
<Dialog open={showSigningDialog} onOpenChange={setShowSigningDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add your signature</DialogTitle>
</DialogHeader>
<DialogDescription>
By signing you signal your support of Documenso's mission in a <br></br>
<strong>non-legally binding, but heartfelt way</strong>. <br></br>
<br></br>You also unlock the option to purchase the early supporter plan including
everything we build this year for fixed price.
</DialogDescription>
<SignaturePad
className="aspect-video w-full rounded-md border"
onChange={setDraftSignatureDataUrl}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowSigningDialog(false)}>
Cancel
</Button>
<Button onClick={() => onSignatureConfirmClick()}>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -0,0 +1,35 @@
import { AnimatePresence, motion } from 'framer-motion';
import { FieldError } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils';
export type FormErrorMessageProps = {
className?: string;
error: FieldError | undefined;
};
export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => {
return (
<AnimatePresence>
{error && (
<motion.p
initial={{
opacity: 0,
y: -10,
}}
animate={{
opacity: 1,
y: 0,
}}
exit={{
opacity: 0,
y: 10,
}}
className={cn('text-xs text-red-500', className)}
>
{error.message}
</motion.p>
)}
</AnimatePresence>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,212 @@
'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>
);
};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,28 @@
import { useState } from 'react';
export type CopiedValue = string | null;
export type CopyFn = (_text: string) => Promise<boolean>;
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
const copy: CopyFn = async (text) => {
if (!navigator?.clipboard) {
console.warn('Clipboard not supported');
return false;
}
// Try to save to clipboard then save it in the state if worked
try {
await navigator.clipboard.writeText(text);
setCopiedText(text);
return true;
} catch (error) {
console.warn('Copy failed', error);
setCopiedText(null);
return false;
}
};
return [copiedText, copy];
}

View File

@ -0,0 +1,128 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { randomUUID } from 'crypto';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { TClaimPlanResponseSchema, ZClaimPlanRequestSchema } from '~/api/claim-plan/types';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<TClaimPlanResponseSchema>,
) {
try {
const { method } = req;
if (method?.toUpperCase() !== 'POST') {
return res.status(405).json({
error: 'Method not allowed',
});
}
const safeBody = ZClaimPlanRequestSchema.safeParse(req.body);
if (!safeBody.success) {
return res.status(400).json({
error: 'Bad request',
});
}
const { email, name, planId, signatureDataUrl, signatureText } = safeBody.data;
const user = await prisma.user.findFirst({
where: {
email: email.toLowerCase(),
},
include: {
Subscription: true,
},
});
if (user && user.Subscription.length > 0) {
return res.status(200).json({
// eslint-disable-next-line turbo/no-undeclared-env-vars
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
});
}
const password = Math.random().toString(36).slice(2, 9);
const passwordHash = hashSync(password);
const { id: userId } = await prisma.user.upsert({
where: {
email: email.toLowerCase(),
},
create: {
email: email.toLowerCase(),
name,
password: passwordHash,
},
update: {
name,
password: passwordHash,
},
});
await redis.set(`user:${userId}:temp-password`, password, {
// expire in 24 hours
ex: 60 * 60 * 24,
});
const signatureDataUrlKey = randomUUID();
if (signatureDataUrl) {
await redis.set(`signature:${signatureDataUrlKey}`, signatureDataUrl, {
// expire in 7 days
ex: 60 * 60 * 24 * 7,
});
}
const metadata: Record<string, string> = {
name,
email,
signatureText: signatureText || name,
source: 'landing',
};
if (signatureDataUrl) {
metadata.signatureDataUrl = signatureDataUrlKey;
}
const checkout = await stripe.checkout.sessions.create({
customer_email: email,
client_reference_id: userId.toString(),
payment_method_types: ['card'],
line_items: [
{
price: planId,
quantity: 1,
},
],
mode: 'subscription',
metadata,
allow_promotion_codes: true,
// eslint-disable-next-line turbo/no-undeclared-env-vars
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
email,
)}&name=${encodeURIComponent(name)}&planId=${planId}&cancelled=true`,
});
if (!checkout.url) {
throw new Error('Checkout URL not found');
}
return res.json({
redirectUrl: checkout.url,
});
} catch (error) {
console.error(error);
return res.status(500).json({
error: 'Internal server error',
});
}
}

View File

@ -0,0 +1,173 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { randomBytes } from 'crypto';
import { readFileSync } from 'fs';
import { buffer } from 'micro';
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
import { redis } from '@documenso/lib/server-only/redis';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
FieldType,
ReadStatus,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
const log = (...args: any[]) => console.log('[stripe]', ...args);
export const config = {
api: { bodyParser: false },
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// eslint-disable-next-line turbo/no-undeclared-env-vars
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
// return res.status(500).json({
// success: false,
// message: 'Subscriptions are not enabled',
// });
// }
const sig =
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
if (!sig) {
return res.status(400).json({
success: false,
message: 'No signature found in request',
});
}
log('constructing body...');
const body = await buffer(req);
log('constructed body');
const event = stripe.webhooks.constructEvent(
body,
sig,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, turbo/no-undeclared-env-vars
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET!,
);
log('event-type:', event.type);
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
if (session.metadata?.source === 'landing') {
const user = await prisma.user.findFirst({
where: {
id: Number(session.client_reference_id),
},
});
if (!user) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
const signatureText = session.metadata?.signatureText || user.name;
let signatureDataUrl = '';
if (session.metadata?.signatureDataUrl) {
const result = await redis.get<string>(`signature:${session.metadata.signatureDataUrl}`);
if (result) {
signatureDataUrl = result;
}
}
const now = new Date();
const document = await prisma.document.create({
data: {
title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED,
userId: user.id,
document: readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'),
created: now,
},
});
const recipient = await prisma.recipient.create({
data: {
name: user.name ?? '',
email: user.email,
token: randomBytes(16).toString('hex'),
signedAt: now,
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.SIGNED,
documentId: document.id,
},
});
const field = await prisma.field.create({
data: {
documentId: document.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 0,
positionX: 77,
positionY: 638,
inserted: false,
customText: '',
},
});
if (signatureDataUrl) {
document.document = await insertImageInPDF(
document.document,
signatureDataUrl,
field.positionX,
field.positionY,
field.page,
);
} else {
document.document = await insertTextInPDF(
document.document,
signatureText ?? '',
field.positionX,
field.positionY,
field.page,
);
}
await Promise.all([
prisma.signature.create({
data: {
fieldId: field.id,
recipientId: recipient.id,
signatureImageAsBase64: signatureDataUrl || undefined,
typedSignature: signatureDataUrl ? '' : signatureText,
},
}),
prisma.document.update({
where: {
id: document.id,
},
data: {
document: document.document,
},
}),
]);
}
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
log('Unhandled webhook event', event.type);
return res.status(400).json({
success: false,
message: 'Unhandled webhook event',
});
}

View File

@ -0,0 +1,13 @@
'use client';
import React from 'react';
import NextPlausibleProvider from 'next-plausible';
export type PlausibleProviderProps = {
children: React.ReactNode;
};
export const PlausibleProvider = ({ children }: PlausibleProviderProps) => {
return <NextPlausibleProvider domain="documenso.com">{children}</NextPlausibleProvider>;
};

View File

@ -0,0 +1,11 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const baseConfig = require('@documenso/tailwind-config');
const path = require('path');
module.exports = {
...baseConfig,
content: [
...baseConfig.content,
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
],
};

View File

@ -0,0 +1,26 @@
{
"extends": "@documenso/tsconfig/nextjs.json",
"compilerOptions": {
"allowJs": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"~/*": [
"./src/*"
]
},
"strictNullChecks": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@ -1,8 +0,0 @@
{
"extends": [
"next/core-web-vitals"
],
"rules": {
"react/no-unescaped-entities": "off"
}
}

37
apps/web/.gitignore vendored
View File

@ -1,37 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

1
apps/web/README.md Normal file
View File

@ -0,0 +1 @@
# @documenso/web

View File

@ -1,70 +0,0 @@
import { useState } from "react";
import { classNames } from "@documenso/lib";
import { STRIPE_PLANS, fetchCheckoutSession, useSubscription } from "@documenso/lib/stripe";
import { Button } from "@documenso/ui";
import { Switch } from "@headlessui/react";
export const BillingPlans = () => {
const { subscription, isLoading } = useSubscription();
const [isAnnual, setIsAnnual] = useState(false);
return (
<div>
{!subscription &&
STRIPE_PLANS.map((plan) => (
<div key={plan.name} className="rounded-lg border py-4 px-6">
<h3 className="text-center text-lg font-medium leading-6 text-gray-900">{plan.name}</h3>
<div className="my-4 flex justify-center">
<Switch.Group as="div" className="flex items-center">
<Switch
checked={isAnnual}
onChange={setIsAnnual}
className={classNames(
isAnnual ? "bg-neon-600" : "bg-gray-200",
"focus:ring-neon-600 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2"
)}>
<span
aria-hidden="true"
className={classNames(
isAnnual ? "translate-x-5" : "translate-x-0",
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
)}
/>
</Switch>
<Switch.Label as="span" className="ml-3 text-sm">
<span className="font-medium text-gray-900">Annual billing</span>{" "}
<span className="text-gray-500">(Save $60)</span>
</Switch.Label>
</Switch.Group>
</div>
<p className="mt-2 text-center text-gray-500">
${(isAnnual ? plan.prices.yearly.price : plan.prices.monthly.price).toFixed(2)}{" "}
<span className="text-sm text-gray-400">{isAnnual ? "/yr" : "/mo"}</span>
</p>
<p className="mt-4 text-center text-sm text-gray-500">
All you need for easy signing. <br></br>Includes everthing we build this year.
</p>
<div className="mt-4">
<Button
className="w-full"
disabled={isLoading}
onClick={() =>
fetchCheckoutSession({
priceId: isAnnual ? plan.prices.yearly.priceId : plan.prices.monthly.priceId,
}).then((res) => {
if (res.success) {
window.location.href = res.url;
}
})
}>
Subscribe
</Button>
</div>
</div>
))}
</div>
);
};

View File

@ -1,51 +0,0 @@
import { useSubscription } from "@documenso/lib/stripe"
import { PaperAirplaneIcon } from "@heroicons/react/24/outline";
import { SubscriptionStatus } from '@prisma/client'
import Link from "next/link";
export const BillingWarning = () => {
const { subscription } = useSubscription();
return (
<>
{subscription?.status === SubscriptionStatus.PAST_DUE && (
<div className="bg-yellow-50 p-4 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-3xl items-start justify-center">
<div className="flex-shrink-0">
<PaperAirplaneIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">
Your subscription is past due.{" "}
<Link href="/account/billing" className="text-yellow-700 underline">
Please update your payment information to avoid any service interruptions.
</Link>
</p>
</div>
</div>
</div>
)}
{subscription?.status === SubscriptionStatus.INACTIVE && (
<div className="bg-red-50 p-4 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-3xl items-center justify-center">
<div className="flex-shrink-0">
<PaperAirplaneIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-red-700">
Your subscription is inactive. You can continue to view and edit your documents,
but you will not be able to send them or create new ones.{" "}
<Link href="/account/billing" className="text-red-700 underline">
You can update your payment information here
</Link>
</p>
</div>
</div>
</div>
)}
</>
)
}

View File

@ -1,86 +0,0 @@
import React, { useState } from "react";
import { IconButton } from "@documenso/ui";
import Logo from "../logo";
import { XCircleIcon } from "@heroicons/react/20/solid";
import Draggable from "react-draggable";
const stc = require("string-to-color");
type FieldPropsType = {
field: {
color: string;
type: string;
position: any;
positionX: number;
positionY: number;
id: string;
Recipient: { name: ""; email: "" };
};
onPositionChanged: any;
onDelete: any;
hidden: boolean;
};
export default function EditableField(props: FieldPropsType) {
const [field, setField]: any = useState(props.field);
const [position, setPosition]: any = useState({
x: props.field.positionX,
y: props.field.positionY,
});
const nodeRef = React.createRef<HTMLDivElement>();
const onControlledDrag = (e: any, position: any) => {
const { x, y } = position;
setPosition({ x, y });
};
const onDragStop = (e: any, position: any) => {
if (!position) return;
const { x, y } = position;
props.onPositionChanged({ x, y }, props.field.id);
};
return (
<Draggable
nodeRef={nodeRef}
bounds="parent"
position={position}
onDrag={onControlledDrag}
onStop={onDragStop}
defaultPosition={{ x: 0, y: 0 }}
cancel="strong"
onMouseDown={(e: any) => {
e.preventDefault();
e.stopPropagation();
}}>
{/* width: 192 height 96 */}
<div
hidden={props.hidden}
ref={nodeRef}
className="absolute top-0 left-0 m-auto h-16 w-48 cursor-move select-none flex-row-reverse p-2 text-center text-lg font-bold opacity-80"
style={{
background: stc(props.field.Recipient.email),
}}>
<div className="m-auto flex-row-reverse overflow-hidden text-center text-lg font-bold">
{field.type}
{field.type === "SIGNATURE" ? (
<div className="text-center text-xs">
{`${props.field.Recipient?.name} <${props.field.Recipient?.email}>`}
</div>
) : (
""
)}
</div>
<strong>
<IconButton
className="absolute top-0 right-0 -m-5"
color="secondary"
icon={XCircleIcon}
onClick={(event: any) => {
props.onDelete(props.field.id);
}}></IconButton>
</strong>
</div>
</Draggable>
);
}

View File

@ -1,68 +0,0 @@
import { useEffect, useState } from "react";
import { classNames } from "@documenso/lib";
import { RadioGroup } from "@headlessui/react";
import { FieldType } from "@prisma/client";
const stc = require("string-to-color");
export default function FieldTypeSelector(props: any) {
const fieldTypes = [
{
name: "Signature",
id: FieldType.SIGNATURE,
},
{ name: "Date", id: FieldType.DATE },
];
const [selectedFieldType, setSelectedFieldType] = useState(fieldTypes[0].id);
useEffect(() => {
props.onChange(selectedFieldType);
}, [selectedFieldType]);
return (
<RadioGroup
value={selectedFieldType}
onChange={(e: any) => {
setSelectedFieldType(e);
}}>
<div className="space-y-4">
{fieldTypes.map((fieldType) => (
<RadioGroup.Option
onMouseDown={(e: any) => {
if (e.button === 0) setSelectedFieldType(fieldType.id);
}}
key={fieldType.id}
value={fieldType.id}
className={({ checked, active }) =>
classNames(
checked ? "border-neon border-2" : "border-transparent",
"relative block cursor-pointer select-none rounded-lg border bg-white px-3 py-2 hover:bg-slate-100 focus:outline-none sm:flex sm:justify-between"
)
}>
{({ active, checked }) => (
<>
<span className="flex items-center">
<span className="flex flex-col text-sm">
<RadioGroup.Label as="span" className="font-medium text-gray-900">
<span
className="mr-3 inline-block h-4 w-4 flex-shrink-0 rounded-full align-middle"
style={{
background: stc(props.selectedRecipient?.email),
}}
/>
<span className="align-middle">
{" "}
{fieldTypes.filter((e) => e.id === fieldType.id)[0].name}
</span>
</RadioGroup.Label>
</span>
</span>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
);
}

View File

@ -1,120 +0,0 @@
import { useState } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter } from "next/router";
import { createField } from "@documenso/features/editor";
import { createOrUpdateField, deleteField } from "@documenso/lib/api";
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib/constants";
import FieldTypeSelector from "./field-type-selector";
import RecipientSelector from "./recipient-selector";
import { InformationCircleIcon } from "@heroicons/react/24/outline";
const stc = require("string-to-color");
const PDFViewer = dynamic(() => import("./pdf-viewer"), {
ssr: false,
});
export default function PDFEditor(props: any) {
const router = useRouter();
const [fields, setFields] = useState<any[]>(props.document.Field);
const [selectedRecipient, setSelectedRecipient]: any = useState();
const [selectedFieldType, setSelectedFieldType] = useState();
const noRecipients =
props?.document.Recipient.length === 0 || props?.document.Recipient.every((e: any) => !e.email);
function onPositionChangedHandler(position: any, id: any) {
if (!position) return;
const movedField = fields.find((e) => e.id == id);
movedField.positionX = position.x.toFixed(0);
movedField.positionY = position.y.toFixed(0);
createOrUpdateField(props.document, movedField);
// no instant redraw neccessary, postion information for saving or later rerender is enough
// setFields(newFields);
}
function onDeleteHandler(id: any) {
const field = fields.find((e) => e.id == id);
const fieldIndex = fields.map((item) => item.id).indexOf(id);
if (fieldIndex > -1) {
const fieldWithoutRemoved = [...fields];
const removedField = fieldWithoutRemoved.splice(fieldIndex, 1);
setFields(fieldWithoutRemoved);
deleteField(field).catch((err) => {
setFields(fieldWithoutRemoved.concat(removedField));
});
}
}
return (
<>
<div>
<div hidden={!noRecipients} className="rounded-md bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<InformationCircleIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3 flex-1 md:flex md:justify-between">
<p className="text-sm text-yellow-700">
This document does not have any recipients. Add recipients to create fields.
</p>
<p className="mt-3 text-sm md:mt-0 md:ml-6">
<Link
href={NEXT_PUBLIC_WEBAPP_URL + "/documents/" + props.document.id + "/recipients"}
className="whitespace-nowrap font-medium text-yellow-700 hover:text-yellow-600">
Add Recipients
<span aria-hidden="true"> &rarr;</span>
</Link>
</p>
</div>
</div>
</div>
<PDFViewer
style={{
cursor: !noRecipients
? `url("https://place-hold.it/110x64/37f095/FFFFFF&text=${selectedFieldType}") 55 32, auto`
: "",
}}
readonly={false}
document={props.document}
fields={fields}
onPositionChanged={onPositionChangedHandler}
onDelete={onDeleteHandler}
pdfUrl={`${NEXT_PUBLIC_WEBAPP_URL}/api/documents/${router.query.id}`}
onMouseUp={(e: any, page: number) => {
e.preventDefault();
e.stopPropagation();
}}
onMouseDown={(e: any, page: number) => {
if (e.button === 0) addField(e, page);
}}></PDFViewer>
<div
hidden={noRecipients}
className="fixed left-0 top-1/3 max-w-xs rounded-md border border-slate-300 bg-white py-4 pr-5">
<RecipientSelector
recipients={props?.document?.Recipient}
onChange={setSelectedRecipient}
/>
<hr className="m-3 border-slate-300"></hr>
<FieldTypeSelector
selectedRecipient={selectedRecipient}
onChange={setSelectedFieldType}
/>
</div>
</div>
</>
);
function addField(e: any, page: number) {
if (!selectedRecipient) return;
if (!selectedFieldType) return;
if (noRecipients) return;
const signatureField = createField(e, page, selectedRecipient, selectedFieldType);
createOrUpdateField(props?.document, signatureField).then((res) => {
setFields((prevState) => [...prevState, res]);
});
}
}

View File

@ -1,184 +0,0 @@
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/router";
import { createField } from "@documenso/features/editor";
import { createOrUpdateField, deleteField, signDocument } from "@documenso/lib/api";
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib/constants";
import { Button } from "@documenso/ui";
import Logo from "../logo";
import SignatureDialog from "./signature-dialog";
import { CheckBadgeIcon, InformationCircleIcon } from "@heroicons/react/24/outline";
import { FieldType } from "@prisma/client";
const PDFViewer = dynamic(() => import("./pdf-viewer"), {
ssr: false,
});
export default function PDFSigner(props: any) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [signingDone, setSigningDone] = useState(false);
const [localSignatures, setLocalSignatures] = useState<any[]>([]);
const [fields, setFields] = useState<any[]>(props.fields);
const signatureFields = fields.filter((field) => field.type === FieldType.SIGNATURE);
const [dialogField, setDialogField] = useState<any>();
useEffect(() => {
setSigningDone(checkIfSigningIsDone());
}, [fields]);
function onClick(item: any) {
if (item.type === "SIGNATURE") {
setDialogField(item);
setOpen(true);
}
}
function onDialogClose(dialogResult: any) {
// todo handle signature removed from field, remove free field if dialogresult is empty (or the id )
if (!dialogResult && dialogField.type === "FREE_SIGNATURE") {
onDeleteHandler(dialogField.id);
return;
}
if (!dialogResult) return;
const signature = {
fieldId: dialogField.id,
type: dialogResult.type,
typedSignature: dialogResult.typedSignature,
signatureImage: dialogResult.signatureImage,
};
setLocalSignatures(localSignatures.concat(signature));
fields.splice(
fields.findIndex(function (i) {
return i.id === signature.fieldId;
}),
1
);
const signedField = { ...dialogField };
signedField.signature = signature;
setFields((prevState) => [...prevState, signedField]);
setOpen(false);
setDialogField(null);
}
return (
<>
<SignatureDialog open={open} setOpen={setOpen} onClose={onDialogClose} />
<div className="bg-neon p-4">
<div className="flex">
<div className="flex-shrink-0">
<Logo className="-mt-2.5 h-12 w-12"></Logo>
</div>
<div className="ml-3 flex-1 items-center justify-start text-center md:flex md:justify-between">
<p className="text-lg text-slate-700">
{props.document.User.name
? `${props.document.User.name} (${props.document.User.email})`
: props.document.User.email}{" "}
would like you to sign this document.
</p>
<p className="mt-3 text-sm md:mt-0 md:ml-6 text-right md:text-inherit">
<Button
disabled={!signingDone}
color="secondary"
icon={CheckBadgeIcon}
onClick={() => {
signDocument(props.document, localSignatures, `${router.query.token}`).then(
() => {
router.push(
`/documents/${props.document.id}/signed?token=${router.query.token}`
);
}
);
}}>
Done
</Button>
</p>
</div>
</div>
</div>
{signatureFields.length === 0 ? (
<div className="bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<InformationCircleIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3 flex-1 md:flex md:justify-between">
<p className="text-sm text-yellow-700">
You can sign this document anywhere you like, but maybe look for a signature line.
</p>
</div>
</div>
</div>
) : null}
<PDFViewer
style={{
cursor:
signatureFields.length === 0
? `url("https://place-hold.it/110x64/37f095/ffffff&text=Signature") 55 32, auto`
: "",
}}
readonly={true}
document={props.document}
fields={fields}
pdfUrl={`${NEXT_PUBLIC_WEBAPP_URL}/api/documents/${router.query.id}?token=${router.query.token}`}
onClick={onClick}
onMouseDown={function onMouseDown(e: any, page: number) {
if (signatureFields.length === 0) addFreeSignature(e, page, props.recipient);
}}
onMouseUp={() => {}}
onDelete={onDeleteHandler}></PDFViewer>
</>
);
function checkIfSigningIsDone(): boolean {
// Check if all fields are signed..
if (signatureFields.length > 0) {
// If there are no fields to sign at least one signature is enough
return fields
.filter((field) => field.type === FieldType.SIGNATURE)
.every((field) => field.signature);
} else {
return localSignatures.length > 0;
}
}
function addFreeSignature(e: any, page: number, recipient: any): any {
const freeSignatureField = createField(e, page, recipient, FieldType.FREE_SIGNATURE);
createOrUpdateField(props.document, freeSignatureField, recipient.token).then((res) => {
setFields((prevState) => [...prevState, res]);
setDialogField(res);
setOpen(true);
});
return freeSignatureField;
}
function onDeleteHandler(id: any) {
const field = fields.find((e) => e.id == id);
const fieldIndex = fields.map((item) => item.id).indexOf(id);
if (fieldIndex > -1) {
const fieldWithoutRemoved = [...fields];
const removedField = fieldWithoutRemoved.splice(fieldIndex, 1);
setFields(fieldWithoutRemoved);
const signaturesWithoutRemoved = [...localSignatures];
const removedSignature = signaturesWithoutRemoved.splice(
signaturesWithoutRemoved.findIndex(function (i) {
return i.fieldId === id;
}),
1
);
setLocalSignatures(signaturesWithoutRemoved);
deleteField(field).catch((err) => {
setFields(fieldWithoutRemoved.concat(removedField));
setLocalSignatures(signaturesWithoutRemoved.concat(removedSignature));
});
}
}
}

View File

@ -1,168 +0,0 @@
import { Fragment, useState } from "react";
import EditableField from "./editable-field";
import SignableField from "./signable-field";
import { FieldType } from "@prisma/client";
import { Document, Page } from "react-pdf/dist/esm/entry.webpack5";
import short from "short-uuid";
export default function PDFViewer(props) {
const [numPages, setNumPages] = useState(null);
const [loading, setLoading] = useState(true);
const [pageHeight, setPageHeight] = useState(0);
function onPositionChangedHandler(position, id) {
props.onPositionChanged(position, id);
}
function onDeleteHandler(id) {
props.onDelete(id);
}
function onDocumentLoadSuccess({ numPages: nextNumPages }) {
setNumPages(nextNumPages);
}
const options = {
cMapUrl: "cmaps/",
cMapPacked: true,
standardFontDataUrl: "standard_fonts/",
};
return (
<>
<div
hidden={loading}
onMouseUp={props.onMouseUp}
style={{ height: numPages * pageHeight + 1000 }}>
<div className="mt-6 max-w-xs"></div>
<Document
file={props.pdfUrl}
onLoadSuccess={onDocumentLoadSuccess}
options={options}
renderMode="canvas"
className="absolute left-0 right-0 mx-auto w-auto">
{Array.from({ length: numPages }, (_, index) => (
<Fragment key={short.generate().toString()}>
<div
onMouseDown={(e) => {
if (e.button === 0) props.onMouseDown(e, index);
}}
onMouseUp={(e) => {
if (e.button === 0) props.onMouseUp(e, index);
}}
key={short.generate().toString()}
style={{
position: "relative",
...props.style,
}}
className="mx-auto w-fit">
<Page
className="mt-5"
key={`page_${index + 1}`}
pageNumber={index + 1}
renderAnnotationLayer={false}
renderTextLayer={false}
onLoadSuccess={(e) => {
if (e.height) setPageHeight(e.height);
setLoading(false);
}}
onRenderError={() => setLoading(false)}></Page>
{props?.fields
.filter((field) => field.page === index)
.map((field) =>
props.readonly ? (
<SignableField
onClick={props.onClick}
key={field.id}
field={field}
className="absolute"
onDelete={onDeleteHandler}></SignableField>
) : (
<EditableField
hidden={
field.Signature ||
field.inserted ||
field.type === FieldType.FREE_SIGNATURE
}
key={field.id}
field={field}
className="absolute"
onPositionChanged={onPositionChangedHandler}
onDelete={onDeleteHandler}></EditableField>
)
)}
</div>
</Fragment>
))}
</Document>
</div>
<div className="mx-auto mt-10 w-[600px]" hidden={!loading}>
<div className="ph-item">
<div className="ph-col-12">
<div className="ph-picture"></div>
<div className="ph-row">
<div className="ph-col-6 big"></div>
<div className="ph-col-4 empty big"></div>
<div className="ph-col-2 big"></div>
<div className="ph-col-4"></div>
<div className="ph-col-8 empty"></div>
<div className="ph-col-6"></div>
<div className="ph-col-6 empty"></div>
<div className="ph-col-12"></div>
</div>
<div className="ph-row">
<div className="ph-col-6 big"></div>
<div className="ph-col-4 empty big"></div>
<div className="ph-col-2 big"></div>
<div className="ph-col-4"></div>
<div className="ph-col-8 empty"></div>
<div className="ph-col-6"></div>
<div className="ph-col-6 empty"></div>
<div className="ph-col-12"></div>
</div>
<div className="ph-row">
<div className="ph-col-6 big"></div>
<div className="ph-col-4 empty big"></div>
<div className="ph-col-2 big"></div>
<div className="ph-col-4"></div>
<div className="ph-col-8 empty"></div>
<div className="ph-col-6"></div>
<div className="ph-col-6 empty"></div>
<div className="ph-col-12"></div>
</div>
<div className="ph-row">
<div className="ph-col-6 big"></div>
<div className="ph-col-4 empty big"></div>
<div className="ph-col-2 big"></div>
<div className="ph-col-4"></div>
<div className="ph-col-8 empty"></div>
<div className="ph-col-6"></div>
<div className="ph-col-6 empty"></div>
<div className="ph-col-12"></div>
</div>
<div className="ph-row">
<div className="ph-col-6 big"></div>
<div className="ph-col-4 empty big"></div>
<div className="ph-col-2 big"></div>
<div className="ph-col-4"></div>
<div className="ph-col-8 empty"></div>
<div className="ph-col-6"></div>
<div className="ph-col-6 empty"></div>
<div className="ph-col-12"></div>
</div>
<div className="ph-row">
<div className="ph-col-6 big"></div>
<div className="ph-col-4 empty big"></div>
<div className="ph-col-2 big"></div>
<div className="ph-col-4"></div>
<div className="ph-col-8 empty"></div>
<div className="ph-col-6"></div>
<div className="ph-col-6 empty"></div>
<div className="ph-col-12"></div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@ -1,93 +0,0 @@
import { Fragment, useEffect, useState } from "react";
import { classNames } from "@documenso/lib";
import { Listbox, Transition } from "@headlessui/react";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/24/outline";
const stc = require("string-to-color");
export default function RecipientSelector(props: any) {
const [selectedRecipient, setSelectedRecipient]: any = useState(props?.recipients[0]);
useEffect(() => {
props.onChange(selectedRecipient);
}, [selectedRecipient]);
return (
<Listbox
value={selectedRecipient}
onChange={(e: any) => {
setSelectedRecipient(e);
}}>
{({ open }) => (
<div className="relative mt-1 mb-2">
<Listbox.Button className="focus:border-neon focus:ring-neon relative w-full cursor-default select-none rounded-md border border-gray-300 bg-white py-2 pl-3 pr-10 text-left focus:outline-none focus:ring-1 sm:text-sm">
<span className="flex items-center">
<span
className="inline-block h-4 w-4 flex-shrink-0 rounded-full"
style={{ background: stc(selectedRecipient?.email) }}
/>
<span className="ml-3 block truncate">
{`${selectedRecipient?.name} <${selectedRecipient?.email}>`}
</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</Listbox.Button>
<Transition
show={open}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{props?.recipients.map((recipient: any) => (
<Listbox.Option
key={recipient?.id}
disabled={!recipient?.email}
className={({ active }) =>
classNames(
active ? "bg-neon-dark text-white" : "text-gray-900",
"relative cursor-default select-none py-2 pl-3 pr-9 aria-disabled:opacity-50 aria-disabled:cursor-not-allowed"
)
}
value={recipient}>
{({ selected, active }) => (
<>
<div className="flex items-center">
<span
className="inline-block h-4 w-4 flex-shrink-0 rounded-full"
style={{
background: stc(recipient?.email),
}}
/>
<span
className={classNames(
selected ? "font-semibold" : "font-normal",
"ml-3 block truncate"
)}>
{`${recipient?.name} <${recipient?.email || 'unknown'}>`}
</span>
</div>
{selected ? (
<span
className={classNames(
active ? "text-white" : "text-neon-dark",
"absolute inset-y-0 right-0 flex items-center pr-4"
)}>
<CheckIcon className="h-5 w-5" strokeWidth={3} aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
);
}

View File

@ -1,90 +0,0 @@
import React, { useState } from "react";
import { classNames } from "@documenso/lib";
import { IconButton } from "@documenso/ui";
import { XCircleIcon } from "@heroicons/react/20/solid";
import Draggable from "react-draggable";
const stc = require("string-to-color");
type FieldPropsType = {
field: {
color: string;
type: string;
position: any;
positionX: number;
positionY: number;
id: string;
Recipient: { name: ""; email: "" };
};
onClick: any;
onDelete: any;
};
export default function SignableField(props: FieldPropsType) {
const [field, setField]: any = useState(props.field);
const [position, setPosition]: any = useState({
x: props.field.positionX,
y: props.field.positionY,
});
const nodeRef = React.createRef<HTMLDivElement>();
return (
<Draggable
nodeRef={nodeRef}
bounds="parent"
position={position}
defaultPosition={{ x: 0, y: 0 }}
cancel="div"
onMouseDown={(e: any) => {
// e.preventDefault();
e.stopPropagation();
}}>
<div
onClick={(e: any) => {
if (!field?.signature) props.onClick(props.field);
}}
ref={nodeRef}
className={classNames(
"absolute top-0 left-0 m-auto h-16 w-48 select-none flex-row-reverse text-center text-lg font-bold opacity-80",
field.type === "SIGNATURE" ? "cursor-pointer hover:brightness-50" : "cursor-not-allowed"
)}
style={{
background: stc(props.field.Recipient.email),
}}>
<div hidden={field?.signature} className="my-4 font-medium">
{field.type === "SIGNATURE" ? "SIGN HERE" : ""}
{field.type === "DATE" ? <small>Date (filled on sign)</small> : ""}
</div>
<div
hidden={!field?.signature}
className="font-qwigley m-auto w-auto flex-row-reverse text-center text-5xl font-medium">
{field?.signature?.type === "type" ? (
<div className="my-4">{field?.signature.typedSignature}</div>
) : (
""
)}
{field?.signature?.type === "draw" ? (
<img className="h-16 w-48" src={field?.signature?.signatureImage} />
) : (
""
)}
<IconButton
icon={XCircleIcon}
color="secondary"
className="absolute top-0 right-0 -m-5"
onClick={(event: any) => {
event.preventDefault();
event.stopPropagation();
const newField = { ...field };
newField.signature = null;
setField(newField);
// remove not only signature but whole field if it is a freely places signature
if (field.type === "FREE_SIGNATURE") props.onDelete(field.id);
}}
/>
</div>
</div>
</Draggable>
);
}

View File

@ -1,207 +0,0 @@
import { Fragment, useEffect, useState } from "react";
import { classNames } from "@documenso/lib";
import { localStorage } from "@documenso/lib";
import { Button, IconButton } from "@documenso/ui";
import { Dialog, Transition } from "@headlessui/react";
import { LanguageIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
import SignatureCanvas from "react-signature-canvas";
import { useDebouncedValue } from "../../hooks/use-debounced-value";
const tabs = [
{ name: "Type", icon: LanguageIcon, current: true },
{ name: "Draw", icon: PencilIcon, current: false },
];
export default function SignatureDialog(props: any) {
const [currentTab, setCurrentTab] = useState(tabs[0]);
const [typedSignature, setTypedSignature] = useState("");
const [signatureEmpty, setSignatureEmpty] = useState(true);
// This is a workaround to prevent the canvas from being rendered when the dialog is closed
// we also need the debounce to avoid rendering while transitions are occuring.
const showCanvas = useDebouncedValue<boolean>(props.open, 1);
let signCanvasRef: any | undefined;
useEffect(() => {
setTypedSignature(localStorage.getItem("typedSignature") || "");
}, []);
return (
<>
<Transition.Root show={props.open} as={Fragment}>
<Dialog
as="div"
className="relative z-10"
onClose={() => {
props.setOpen(false);
setCurrent(tabs[0]);
}}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative min-h-[350px] transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
<div className="">
<div className="mb-3 border-b border-gray-200">
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
{tabs.map((tab) => (
<a
key={tab.name}
onClick={() => {
setCurrent(tab);
}}
className={classNames(
tab.current
? "border-neon text-neon"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
"group inline-flex cursor-pointer items-center border-b-2 py-4 px-1 text-sm font-medium"
)}
aria-current={tab.current ? "page" : undefined}>
<tab.icon
className={classNames(
tab.current
? "text-neon"
: "text-gray-400 group-hover:text-gray-500",
"-ml-0.5 mr-2 h-5 w-5"
)}
aria-hidden="true"
/>
<span>{tab.name}</span>
</a>
))}
</nav>
</div>
{isCurrentTab("Type") ? (
<div>
<div className="my-7 mb-3 border-b border-gray-300">
<input
value={typedSignature}
onChange={(e) => {
setTypedSignature(e.target.value);
}}
className={classNames(
typedSignature ? "font-qwigley text-4xl" : "",
"focus:border-neon focus:ring-neon mt-14 block h-10 w-full text-center align-bottom text-2xl leading-none"
)}
placeholder="Kindly type your name"
/>
</div>
<div className="flex flex-row-reverse items-center gap-x-3">
<Button
color="secondary"
onClick={() => {
props.onClose();
props.setOpen(false);
setCurrent(tabs[0]);
}}>
Cancel
</Button>
<Button
className="ml-3"
disabled={!typedSignature}
onClick={() => {
localStorage.setItem("typedSignature", typedSignature);
props.onClose({
type: "type",
typedSignature: typedSignature,
});
}}>
Sign
</Button>
</div>
</div>
) : (
""
)}
{isCurrentTab("Draw") ? (
<div className="" key={props.open ? "closed" : "open"}>
{showCanvas && (
<SignatureCanvas
ref={(ref) => {
signCanvasRef = ref;
}}
canvasProps={{
className: "sigCanvas border-b b-2 border-slate w-full h-full mb-3",
}}
clearOnResize={true}
onEnd={() => {
setSignatureEmpty(signCanvasRef?.isEmpty());
}}
/>
)}
<div className="flex items-center justify-between">
<IconButton
className="block"
icon={TrashIcon}
onClick={() => {
signCanvasRef?.clear();
setSignatureEmpty(signCanvasRef?.isEmpty());
}}
/>
<div className="flex flex-row-reverse items-center gap-x-3">
<Button
color="secondary"
onClick={() => {
props.onClose();
props.setOpen(false);
setCurrent(tabs[0]);
}}>
Cancel
</Button>
<Button
className="ml-3"
onClick={() => {
props.onClose({
type: "draw",
signatureImage: signCanvasRef.toDataURL("image/png"),
});
}}
disabled={signatureEmpty}>
Sign
</Button>
</div>
</div>
</div>
) : (
""
)}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
</>
);
function isCurrentTab(tabName: string): boolean {
return currentTab.name === tabName;
}
function setCurrent(t: any) {
tabs.forEach((tab) => {
tab.current = tab.name === t.name;
});
setCurrentTab(t);
}
}

View File

@ -1,53 +0,0 @@
import { useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib/constants";
import { useSubscription } from "@documenso/lib/stripe";
import Navigation from "./navigation";
import { PaperAirplaneIcon } from "@heroicons/react/24/outline";
import { SubscriptionStatus } from "@prisma/client";
import { useSession } from "next-auth/react";
import { BillingWarning } from "./billing-warning";
function useRedirectToLoginIfUnauthenticated() {
const { data: session, status } = useSession();
const loading = status === "loading";
const router = useRouter();
useEffect(() => {
if (!loading && !session) {
router.replace({
pathname: "/login",
query: {
callbackUrl: `${NEXT_PUBLIC_WEBAPP_URL}/${location.pathname}${location.search}`,
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, session]);
return {
loading: loading && !session,
session,
};
}
export default function Layout({ children }: any) {
useRedirectToLoginIfUnauthenticated();
const { subscription } = useSubscription();
return (
<>
<div className="min-h-full">
<Navigation />
<main>
<BillingWarning />
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">{children}</div>
</main>
</div>
</>
);
}

View File

@ -1,165 +0,0 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib/constants";
import { Button } from "@documenso/ui";
import Logo from "./logo";
import { LockClosedIcon } from "@heroicons/react/20/solid";
import { signIn } from "next-auth/react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
interface LoginValues {
email: string;
password: string;
totpCode: string;
csrfToken: string;
}
export default function Login(props: any) {
const router = useRouter();
const methods = useForm<LoginValues>();
const { register, formState } = methods;
const [errorMessage, setErrorMessage] = useState<string | null>(null);
let callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : "";
// If not absolute URL, make it absolute
if (!/^https?:\/\//.test(callbackUrl)) {
callbackUrl = `${NEXT_PUBLIC_WEBAPP_URL}/${callbackUrl}`;
}
const onSubmit = async (values: LoginValues) => {
setErrorMessage(null);
const res = await toast.promise(
signIn<"credentials">("credentials", {
...values,
callbackUrl,
redirect: false,
}),
{
loading: "Logging in...",
success: "Login successful.",
error: "Could not log in :/",
},
{
style: {
minWidth: "200px",
},
}
);
if (!res) {
setErrorMessage("Error");
toast.dismiss();
toast.error("Something went wrong.");
} else if (!res.error) {
// we're logged in, let's do a hard refresh to the original url
router.push(callbackUrl);
} else {
toast.dismiss();
if (res.status == 401) {
toast.error("Invalid email or password.");
} else {
toast.error("Could not login.");
}
}
};
return (
<>
<div className="flex min-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-md space-y-8">
<div>
<Logo className="mx-auto h-10 w-auto"></Logo>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
Sign in to your account
</h2>
</div>
<FormProvider {...methods}>
<form className="mt-8 space-y-6" onSubmit={methods.handleSubmit(onSubmit)}>
<input type="hidden" name="remember" defaultValue="true" />
<div className="-space-y-px rounded-md shadow-sm">
<div>
<label htmlFor="email-address" className="sr-only">
Email
</label>
<input
{...register("email")}
id="email-address"
name="email"
type="email"
autoComplete="email"
required
className="focus:border-neon focus:ring-neon relative block w-full appearance-none rounded-none rounded-t-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:outline-none sm:text-sm"
placeholder="Email"
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
{...register("password")}
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="focus:border-neon focus:ring-neon relative block w-full appearance-none rounded-none rounded-b-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:outline-none sm:text-sm"
placeholder="Password"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm">
<a href="#" className="text-gray-500 hover:text-neon-700 font-medium">
Forgot your password?
</a>
</div>
</div>
<div>
<Button
type="submit"
disabled={formState.isSubmitting}
className="group relative flex w-full">
<span className="absolute inset-y-0 left-0 flex items-center pl-3">
<LockClosedIcon
className="text-neon-700 group-hover:text-neon-dark-700 h-5 w-5 disabled:disabled:bg-gray-600 disabled:group-hover:bg-gray-600 duration-200"
aria-hidden="true"
/>
</span>
Sign in
</Button>
</div>
<div>
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center"></div>
</div>
</div>
{props.allowSignup ? (
<p className="mt-2 text-center text-sm text-gray-600">
Are you new here?{" "}
<Link href="/signup" className="text-gray-500 hover:text-neon-700 duration-200 font-medium">
Create a new Account
</Link>
</p>
) : (
<p className="mt-2 text-center text-sm text-gray-600">
Like Documenso{" "}
<Link
href="https://documenso.com"
className="text-neon hover:text-neon font-medium">
Hosted Documenso will be available soon
</Link>
</p>
)}
</form>
</FormProvider>
</div>
</div>
{/* <Toaster position="top-center" /> */}
</>
);
}

View File

@ -1,19 +0,0 @@
import Link from "next/link";
import { classNames } from "@documenso/lib";
export default function Logo(props: any) {
return (
<>
<Link href="/dashboard">
<svg className="w-12" viewBox="0 0 88.6758041381836 32.18000030517578" {...props}>
<rect width="88.6758041381836" height="32.18000030517578" fill="transparent"></rect>
<g transform="matrix(1,0,0,1,-25.98720359802246,-66.41200256347656)">
<path
d="M99.004,68.26l-.845-1.848-23.226,2.299c-1.437,.142-2.876,.089-4.3-.156-2.949-.51-6.32-1.717-9.073-2.099-1.477-.206-2.934,.448-3.789,1.67-1.174,1.678-2.308,3.888-3.501,5.622-1.518,2.207-3.032,4.418-4.531,6.638-1.693,2.499-3.365,5.013-5.061,7.51-.103,.153-.333,.221-.503,.329-.079-.279-.306-.636-.206-.824,1.006-1.928,1.845-4,3.165-5.694,1.908-2.449,2.914-5.445,5.007-7.745,.342-.777,.845-1.466,1.151-2.244,.915-2.341-1.295-5.305-2.935-5.305h-.613c-1.196,0-2.526,.357-4.092,1.716-.986,.856-2.391,2.432-2.97,3.326-3.424,5.286-7.177,10.382-10.15,15.932-.365,.682-1.719,3.722-2.013,3.606-.214-.077-.159-.458-.041-.823,1.312-4.065,4.163-9.851,5.843-13.777,.41-.962,.635-1.516-.305-2.361-1.016-.913-2.669,.084-3.084,1.052-1.784,4.174-2.631,8.08-4.038,12.396-.466,1.43-.916,2.865-1.386,4.294-.273,.831-.548,1.661-.836,2.488-.112,.321-.226,.642-.345,.962-.032,.082-.064,.165-.095,.248-.006,.011-.003,.002-.009,.017-.005,.008-.003,.015-.006,.023-.011,.026-.021,.05-.03,.076-.024,.067-.012,.044,.009-.002-.691,1.577,.417,3.002,2.091,3.002,.808,0,1.552-1.431,1.943-2.055,1.224-1.957,3.28-5.125,4.36-7.163,.894-1.69,2.078-3.14,3.209-4.615,1.857-2.42,3.065-5.242,5.025-7.575,.33-.394,.694-.772,1.093-1.093,.121-.098,.473-.05,.618,.062,.124,.098,.2,.432,.127,.577-.397,.788-.863,1.542-1.278,2.322-1.481,2.79-2.953,5.582-4.425,8.377-1.16,2.204-2.659,5.055-3.551,6.755-.438,.833-.176,1.857,.604,2.382,0,0-.383,2.028,1.908,2.028,1.896,0,2.711-1.145,3.053-1.624,.348-.486,.748-.951,1.202-1.334,.151-.13,.563-.018,.821,.079,.076,.029,.085,.406,.03,.582-.398,1.272-.323,2.283,1.879,2.283,.784,0,2.218-1.283,2.904-2.213,.794-1.075,.731-1.1,1.415-2.244,1.678-2.821,3.132-5.055,4.751-7.91,1.083-1.91,2.175-3.854,3.294-5.516,.685-1.015,1.446-1.252,2.188-1.252s.409,.686,.336,1.025c-.071,.341-.677,1.545-2.531,4.35-1.115,1.687-2.244,3.367-3.308,5.084-1.075,1.736-2.141,3.48-3.205,5.225-.88,1.445,.162,3.467,1.854,3.467h2.765c2.653,0,5.302-.397,7.916-.851l7.41-1.287c1.421-.245,2.868-.298,4.303-.158l23.161,2.296,.845-1.849c4.61-9.858,15.211-11.322,15.66-11.38v-5.717c-.448-.058-11.05-1.522-15.66-11.381Zm-40.143,6.165c-.164,.465-.491,.922-.86,1.255-.918,.828-1.451,1.842-1.911,2.977-.512,1.269-1.829,2.119-1.966,3.65-.027,.3-.627,.577-1.006,.797-.109,.062-.357-.114-.524-.174,.015-.229-.024-.401,.039-.515,.848-1.495,1.678-3.002,2.584-4.462,.775-1.254,1.642-2.453,2.469-3.677,.085-.126,.158-.273,.27-.365,.457-.368,.93-.719,1.399-1.075,.497,.721-.312,1.071-.494,1.589Zm30.047,12.011c1.736,0,3.162-1.137,3.686-2.693h10.547c-3.02,1.916-6.1,4.684-8.402,8.716l-21.902-2.17v-15.584l21.902-2.167c2.302,4.032,5.382,6.802,8.402,8.714h-10.547c-.524-1.554-1.951-2.691-3.686-2.691-2.172,0-3.938,1.763-3.938,3.938s1.766,3.938,3.938,3.938Z"
className={classNames(props.dark ? "fill-white" : "fill-brown")}></path>
</g>
</svg>
</Link>
</>
);
}

View File

@ -1,266 +0,0 @@
import { Fragment, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { getUser } from "@documenso/lib/api";
import Logo from "./logo";
import { Disclosure, Menu, Transition } from "@headlessui/react";
import {
ArrowRightOnRectangleIcon,
Bars3Icon,
BellIcon,
ChartBarIcon,
DocumentTextIcon,
UserCircleIcon,
WrenchIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import avatarFromInitials from "avatar-from-initials";
import { signOut, useSession } from "next-auth/react";
import { toast } from "react-hot-toast";
const navigation = [
{
name: "Dashboard",
href: "/dashboard",
current: false,
icon: ChartBarIcon,
},
{
name: "Documents",
href: "/documents",
current: false,
icon: DocumentTextIcon,
},
{
name: "Settings",
href: "/settings/profile",
current: true,
icon: WrenchIcon,
},
];
const userNavigation = [
{
name: "Your Profile",
href: "/settings/profile",
icon: UserCircleIcon,
},
{
name: "Sign out",
href: "",
click: async (e: any) => {
e.preventDefault();
const res: any = await toast.promise(
signOut({ callbackUrl: "/login" }),
{
loading: "Logging out...",
success: "Your are logged out.",
error: "Could not log out :/",
},
{
style: {
minWidth: "200px",
},
success: {
duration: 10000,
},
}
);
},
icon: ArrowRightOnRectangleIcon,
},
];
function classNames(...classes: any) {
return classes.filter(Boolean).join(" ");
}
type UserType = {
id?: number | undefined;
name?: string | null;
email?: string | null;
image?: string | null;
};
export default function TopNavigation() {
const router = useRouter();
const session = useSession();
const [user, setUser] = useState({
email: "",
name: "",
});
useEffect(() => {
getUser().then((res) => {
res.json().then((j: any) => {
setUser(j);
});
});
}, [session]);
navigation.forEach((element) => {
element.current =
router.route.endsWith("/" + element.href.split("/")[1]) ||
router.route.includes(element.href.split("/")[1]);
});
return (
<>
<Disclosure as="nav" className="border-b border-gray-200 bg-white">
{({ open, close }) => (
<>
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 justify-between">
<div className="flex">
<div className="flex flex-shrink-0 items-center">
<Logo></Logo>
</div>
<div className="hidden sm:-my-px sm:ml-6 sm:flex sm:space-x-8">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className={classNames(
item.current
? "border-neon text-brown"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
"inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium"
)}
aria-current={item.current ? "page" : undefined}>
<item.icon
className="-ml-1 mr-3 inline h-6 w-6 flex-shrink-0"
aria-hidden="true"></item.icon>
{item.name}
</Link>
))}
</div>
</div>
<div
onClick={() => {
document?.getElementById("mb")?.click();
}}
className="hidden cursor-pointer px-3 hover:bg-gray-200 sm:ml-6 sm:flex sm:items-center">
<span className="text-sm">
<p className="font-bold">{user?.name || ""}</p>
<p>{user?.email}</p>
</span>
<Menu as="div" className="relative ml-3">
<div>
<Menu.Button
id="mb"
className="flex max-w-xs items-center rounded-full bg-white text-sm">
<span className="sr-only">Open user menu</span>
<div
key={user?.email}
dangerouslySetInnerHTML={{
__html: avatarFromInitials(user?.name || "" || "", 40),
}}
/>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
<Menu.Items className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{userNavigation.map((item) => (
<Menu.Item key={item.name}>
{({ active }) => (
<Link
href={item.href}
onClick={item.click}
className={classNames(
active ? "bg-gray-100" : "",
"block px-4 py-2 text-sm text-gray-700"
)}>
<item.icon
className="-ml-1 mr-3 inline h-6 w-6 flex-shrink-0"
aria-hidden="true"></item.icon>
{item.name}
</Link>
)}
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Menu>
</div>
<div className="-mr-2 flex items-center sm:hidden">
{/* Mobile menu button */}
<Disclosure.Button className="inline-flex items-center justify-center rounded-md bg-white p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500">
<span className="sr-only">Open main menu</span>
{open ? (
<XMarkIcon className="block h-6 w-6" aria-hidden="true" />
) : (
<Bars3Icon className="block h-6 w-6" aria-hidden="true" />
)}
</Disclosure.Button>
</div>
</div>
</div>
<Disclosure.Panel className="sm:hidden">
<div className="space-y-1 pt-2 pb-3">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className={classNames(
item.current
? "border-teal-500 bg-teal-50 text-teal-700"
: "border-transparent text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-800",
"block border-l-4 py-2 pl-3 pr-4 text-base font-medium"
)}
aria-current={item.current ? "page" : undefined}
onClick={() => {
close();
}}>
{item.name}
</Link>
))}
</div>
<div className="border-t border-gray-200 pt-4 pb-3">
<div className="flex items-center px-4">
<div className="flex-shrink-0">
<div
key={user?.email}
dangerouslySetInnerHTML={{
__html: avatarFromInitials(user?.name || "" || "", 40),
}}
/>
</div>
<div className="ml-3">
<div className="text-base font-medium text-gray-800">{user?.name || ""}</div>
<div className="text-sm font-medium text-gray-500">{user?.email}</div>
</div>
</div>
<div className="mt-3 space-y-1">
{userNavigation.map((item) => (
<Link
key={item.name}
onClick={
item.href.includes("/settings/profile")
? () => {
close();
}
: item.click
}
href={item.href}
className="block px-4 py-2 text-base font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-800">
{item.name}
</Link>
))}
</div>
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
{/* <Toaster position="top-center"></Toaster> */}
</>
);
}

View File

@ -1,272 +0,0 @@
import { ChangeEvent, useEffect, useState } from "react";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { updateUser } from "@documenso/features";
import { getUser } from "@documenso/lib/api";
import { fetchPortalSession, isSubscriptionsEnabled, useSubscription } from "@documenso/lib/stripe";
import { Button } from "@documenso/ui";
import { BillingPlans } from "./billing-plans";
import { CreditCardIcon, KeyIcon, UserCircleIcon } from "@heroicons/react/24/outline";
import { SubscriptionStatus } from "@prisma/client";
import { useSession } from "next-auth/react";
const subNavigation = [
{
name: "Profile",
href: "/settings/profile",
icon: UserCircleIcon,
current: true,
},
{
name: "Password",
href: "/settings/password",
icon: KeyIcon,
current: false,
}
];
if (process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true") {
subNavigation.push({
name: "Billing",
href: "/settings/billing",
icon: CreditCardIcon,
current: false,
});
}
function classNames(...classes: any) {
return classes.filter(Boolean).join(" ");
}
export default function Setttings() {
const session = useSession();
const { subscription, hasSubscription } = useSubscription();
const [user, setUser] = useState({
email: "",
name: "",
});
useEffect(() => {
getUser().then((res: any) => {
res.json().then((j: any) => {
setUser(j);
});
});
}, [session]);
const router = useRouter();
subNavigation.forEach((element) => {
element.current = element.href == router.route;
});
const [savingTimeout, setSavingTimeout] = useState<any>();
function handleNameChange(e: ChangeEvent<HTMLInputElement>): void {
let u = { ...user };
u.name = e.target.value;
setUser(u);
clearTimeout(savingTimeout);
const t = setTimeout(() => {
updateUser(u);
}, 1000);
setSavingTimeout(t);
}
const handleKeyPress = (event: any) => {
if (event.key === "Enter") {
clearTimeout(savingTimeout);
updateUser(user);
}
};
return (
<div>
<Head>
<title>Settings | Documenso</title>
</Head>
<header className="py-10">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<h1 className="text-brown text-3xl font-bold leading-tight tracking-tight">Settings</h1>
</div>
</header>
<div
className="mx-auto max-w-screen-xl px-4 pb-6 sm:px-6 lg:px-8 lg:pb-16"
hidden={!user.email}>
<div className="overflow-hidden rounded-lg bg-white shadow">
<div className="divide-y divide-gray-200 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
<aside className="py-6 lg:col-span-3">
<nav className="space-y-1">
{subNavigation.map((item) => (
<Link
key={item.name}
href={item.href}
className={classNames(
item.current
? "border-neon-dark bg-teal-50 text-teal-700 hover:bg-teal-50 hover:text-teal-700"
: "border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900",
"group flex items-center border-l-4 px-3 py-2 text-sm font-medium"
)}
aria-current={item.current ? "page" : undefined}>
<item.icon
className={classNames(
item.current
? "text-teal-500 group-hover:text-teal-500"
: "text-gray-400 group-hover:text-gray-500",
"-ml-1 mr-3 h-6 w-6 flex-shrink-0"
)}
aria-hidden="true"
/>
<span className="truncate">{item.name}</span>
</Link>
))}
</nav>
</aside>
<form
className="min-h-[251px] divide-y divide-gray-200 lg:col-span-9"
action="#"
method="POST"
hidden={subNavigation.filter((e) => e.current)[0]?.name !== subNavigation[0].name}>
{/* Profile section */}
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg font-medium leading-6 text-gray-900">Profile</h2>
<p className="mt-1 text-sm text-gray-500">
Let people know who they are dealing with builds trust.
</p>
</div>
<div className="my-6 grid grid-cols-12 gap-6">
<div className="col-span-12 sm:col-span-6">
<label htmlFor="first-name" className="block text-sm font-medium text-gray-700">
Full Name
</label>
<input
type="text"
name="first-name"
id="first-name"
value={user?.name || ""}
onChange={(e) => handleNameChange(e)}
onKeyDown={handleKeyPress}
autoComplete="given-name"
className="focus:border-neon focus:ring-neon mt-1 block w-full rounded-md border border-gray-300 py-2 px-3 shadow-sm focus:outline-none sm:text-sm"
/>
</div>
<div className="col-span-12 sm:col-span-6">
<label htmlFor="first-name" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
disabled
value={user?.email!}
type="text"
name="first-name"
id="first-name"
autoComplete="given-name"
className="focus:border-neon focus:ring-neon mt-1 block w-full rounded-md border border-gray-300 py-2 px-3 shadow-sm focus:outline-none disabled:bg-neutral-100 sm:text-sm"
/>
</div>
</div>
<Button onClick={() => updateUser(user)}>Save</Button>
</div>
</form>
<div
hidden={subNavigation.filter((e) => e.current)[0]?.name !== subNavigation[1].name}
className="min-h-[251px] divide-y divide-gray-200 lg:col-span-9">
{/* Passwords section */}
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg font-medium leading-6 text-gray-900">Password</h2>
<p className="mt-1 text-sm text-gray-500">
Forgot your passwort? Email <b>hi@documenso.com</b> to reset it.
</p>
</div>
</div>
</div>
<div
hidden={!subNavigation.at(2) || subNavigation.find((e) => e.current)?.name !== subNavigation.at(2)?.name}
className="min-h-[251px] divide-y divide-gray-200 lg:col-span-9">
{/* Billing section */}
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg font-medium leading-6 text-gray-900">Billing</h2>
{!isSubscriptionsEnabled() && (
<p className="mt-2 text-sm text-gray-500">
Subscriptions are not enabled on this instance, you have nothing to do here.
</p>
)}
{isSubscriptionsEnabled() && (
<>
<p className="mt-1 text-sm text-gray-500">
Your subscription is currently{" "}
<strong>
{subscription?.status &&
subscription?.status !== SubscriptionStatus.INACTIVE
? "Active"
: "Inactive"}
</strong>
.
</p>
{subscription?.status === SubscriptionStatus.PAST_DUE && (
<p className="mt-1 text-sm text-red-500">
Your subscription is past due. Please update your payment details to
continue using the service without interruption.
</p>
)}
<div className="mt-8">
<div className="grid grid-cols-1 lg:grid-cols-2">
<BillingPlans />
</div>
{subscription && (
<Button
onClick={() => {
if (isSubscriptionsEnabled() && subscription?.customerId) {
fetchPortalSession({
id: subscription.customerId,
}).then((res) => {
if (res.success) {
window.location.href = res.url;
}
});
}
}}>
Manage my subscription
</Button>
)}
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="mt-10 max-w-[1100px]" hidden={!!user.email}>
<div className="ph-item">
<div className="ph-col-12">
<div className="ph-picture"></div>
<div className="ph-row">
<div className="ph-col-6 big"></div>
<div className="ph-col-4 empty big"></div>
<div className="ph-col-2 big"></div>
<div className="ph-col-4"></div>
<div className="ph-col-8 empty"></div>
<div className="ph-col-6"></div>
<div className="ph-col-6 empty"></div>
<div className="ph-col-12"></div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,200 +0,0 @@
import Link from "next/link";
import { signup } from "@documenso/lib/api";
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib/constants";
import { Button } from "@documenso/ui";
import { XCircleIcon } from "@heroicons/react/24/outline";
import { signIn } from "next-auth/react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
type FormValues = {
email: string;
password: string;
apiError: string;
};
export default function Signup(props: { source: string }) {
const form = useForm<FormValues>({});
const {
register,
trigger,
formState: { errors, isSubmitting },
} = form;
const handleErrors = async (resp: Response) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
}
};
const signUp: SubmitHandler<FormValues> = async (data) => {
await toast
.promise(
signup(props.source, data)
.then(handleErrors)
.then(async () => {
await signIn<"credentials">("credentials", {
...data,
callbackUrl: `${NEXT_PUBLIC_WEBAPP_URL}/dashboard`,
});
}),
{
loading: "Creating your account...",
success: "Done!",
error: (err) => err.message,
},
{
style: {
minWidth: "200px",
},
}
)
.catch((err) => {
toast.dismiss();
form.setError("apiError", { message: err.message });
});
};
function renderApiError() {
if (!errors.apiError) return;
return (
<div className="rounded-md bg-red-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
{errors.apiError && <div>{errors.apiError?.message}</div>}
</h3>
</div>
</div>
</div>
);
}
function renderFormValidation() {
if (!errors.password && !errors.email) return;
return (
<div className="rounded-md bg-red-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
{errors.password && <div>{errors.password?.message}</div>}
</h3>
<h3 className="text-sm font-medium text-red-800">
{errors.email && <div>{errors.email?.message}</div>}
</h3>
</div>
</div>
</div>
);
}
return (
<>
<div className="flex min-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
Create a shiny, new <br></br>Documenso Account{" "}
<svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="rgb(17 24 39 / var(--tw-text-opacity))"
className="mb-1 inline h-8 w-8">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Create your account and start using<br></br>
state-of-the-art document signing for free.
</p>
</div>
{renderApiError()}
{renderFormValidation()}
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(signUp)}
onChange={() => {
form.clearErrors();
trigger();
}}
className="mt-8 space-y-6">
<input type="hidden" name="remember" defaultValue="true" />
<div className="-space-y-px rounded-md shadow-sm">
<div>
<label htmlFor="email-address" className="sr-only">
Email
</label>
<input
{...register("email")}
id="email-address"
name="email"
type="email"
autoComplete="email"
required
className="focus:border-neon focus:ring-neon relative block w-full appearance-none rounded-none rounded-t-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:outline-none sm:text-sm"
placeholder="Email"
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
{...register("password", {
minLength: {
value: 7,
message: "Your password has to be at least 7 characters long.",
},
})}
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="focus:border-neon focus:ring-neon relative block w-full appearance-none rounded-none rounded-b-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:outline-none sm:text-sm"
placeholder="Password"
/>
</div>
</div>
<Button
type="submit"
onClick={() => {
form.clearErrors();
}}
className="sgroup relative flex w-full">
Create Account
</Button>
<div className="pt-2">
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center"></div>
</div>
</div>
<p className="mt-2 text-center text-sm text-gray-600">
Already have an account?{" "}
<Link href="/login" className="text-gray-500 hover:text-neon-700 font-medium">
Sign In
</Link>
</p>
</form>
</FormProvider>
</div>
</div>
</>
);
}

View File

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

6
apps/web/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -1,25 +1,15 @@
require("dotenv").config({ path: "../../.env" });
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path');
const { parsed: env } = require('dotenv').config({
path: path.join(__dirname, '../../.env.local'),
});
/** @type {import('next').NextConfig} */
const nextConfig = {
const config = {
reactStrictMode: true,
swcMinify: false,
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
env,
};
const transpileModules = require("next-transpile-modules")([
"@documenso/prisma",
"@documenso/lib",
"@documenso/ui",
"@documenso/pdf",
"@documenso/features",
"@documenso/signing",
"react-signature-canvas",
]);
const plugins = [
transpileModules
];
const moduleExports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
module.exports = moduleExports;
module.exports = config;

10729
apps/web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,65 +2,43 @@
"name": "@documenso/web",
"version": "0.1.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
"dev": "next dev",
"dev": "PORT=3000 next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db-studio": "prisma db studio",
"stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook"
"lint": "next lint"
},
"dependencies": {
"@documenso/lib": "*",
"@documenso/pdf": "*",
"@documenso/prisma": "*",
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@headlessui/react": "^1.7.4",
"@heroicons/react": "^2.0.13",
"@pdf-lib/fontkit": "^1.1.1",
"avatar-from-initials": "^1.0.3",
"base64-arraybuffer": "^1.0.2",
"bcryptjs": "^2.4.3",
"formidable": "^3.2.5",
"next": "13.2.4",
"next-auth": "^4.22.0",
"node-forge": "^1.3.1",
"node-signpdf": "^1.5.0",
"nodemailer": "^6.9.0",
"nodemailer-sendgrid": "^1.0.3",
"pdf-lib": "^1.17.1",
"placeholder-loading": "^0.6.0",
"@hookform/resolvers": "^3.1.0",
"@tanstack/react-query": "^4.29.5",
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
"lucide-react": "^0.214.0",
"micro": "^10.0.1",
"next": "13.4.1",
"next-auth": "^4.22.1",
"next-plausible": "^3.7.2",
"next-themes": "^0.2.1",
"perfect-freehand": "^1.2.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-draggable": "^4.4.5",
"react-hook-form": "^7.41.5",
"react-pdf": "^6.2.2",
"react-resizable": "^3.0.4",
"react-tooltip": "^5.7.2",
"short-uuid": "^4.2.2",
"string-to-color": "^2.2.2"
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.43.9",
"react-icons": "^4.8.0",
"react-pdf": "^7.1.1",
"typescript": "5.0.4",
"zod": "^3.21.4"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.3",
"@types/bcryptjs": "^2.4.2",
"@types/filesystem": "^0.0.32",
"@types/formidable": "^2.0.5",
"@types/node": "^18.11.18",
"@types/nodemailer": "^6.4.7",
"@types/nodemailer-sendgrid": "^1.0.0",
"@types/react-dom": "18.0.9",
"@types/react-pdf": "^6.2.0",
"@types/react-resizable": "^3.0.3",
"autoprefixer": "^10.4.13",
"dotenv": "^16.0.3",
"eslint": "8.27.0",
"eslint-config-next": "13.0.3",
"file-loader": "^6.2.0",
"next-transpile-modules": "^10.0.0",
"postcss": "^8.4.19",
"sass": "^1.57.1",
"stripe-cli": "^0.1.0",
"tailwindcss": "^3.2.4",
"typescript": "4.8.4"
"@types/formidable": "^2.0.6",
"@types/node": "20.1.0",
"@types/react": "18.2.6",
"@types/react-dom": "18.2.4"
}
}
}

View File

@ -1,34 +0,0 @@
import { Button } from "@documenso/ui";
import Logo from "../components/logo";
import { ArrowSmallLeftIcon } from "@heroicons/react/20/solid";
export default function Custom404() {
return (
<>
<main className="relative isolate min-h-full bg-gray-100">
<div className="absolute top-10 left-10">
<Logo className="w-10 md:w-20" />
</div>
<div className="mx-auto max-w-7xl px-6 py-48 text-center sm:py-40 lg:px-8">
<p className="text-brown text-base font-semibold leading-8">404</p>
<h1 className="text-brown mt-4 text-3xl font-bold tracking-tight sm:text-5xl">
Page not found
</h1>
<p className="mt-4 text-base text-gray-700 sm:mt-6">
Sorry, we couldnt find the page youre looking for.
</p>
<div className="mt-10 flex justify-center">
<Button
color="secondary"
href="/"
icon={ArrowSmallLeftIcon}
className="text-brown text-base font-semibold leading-7">
Back to home
</Button>
</div>
</div>
</main>
</>
);
}

View File

@ -1,31 +0,0 @@
import { Button } from "@documenso/ui";
import Logo from "../components/logo";
import { ArrowSmallLeftIcon } from "@heroicons/react/20/solid";
import { EllipsisVerticalIcon } from "@heroicons/react/20/solid";
export default function Custom500() {
return (
<>
<div className="relative flex min-h-full flex-col items-center justify-center bg-black text-white">
<div className="absolute top-10 left-10">
<Logo dark className="w-10 md:w-20" />
</div>
<div className="mt-20 max-w-7xl px-4 py-10">
<p className="inline-flex items-center text-3xl font-bold sm:text-5xl">
500
<span className="relative -top-1.5 px-3 font-thin sm:text-6xl">|</span>{" "}
<span className="align-middle text-base font-semibold sm:text-2xl">
Something went wrong.
</span>
</p>
<div className="mt-10 flex justify-center">
<Button color="secondary" href="/" icon={ArrowSmallLeftIcon}>
Back to home
</Button>
</div>
</div>
</div>
</>
);
}

View File

@ -1,30 +0,0 @@
# Docker config for render.com
# Be sure to add an .env config to your docker web service
FROM node:19.5.0-alpine
RUN apk add --no-cache openjdk11
WORKDIR /app
# Inserted from render.com ENV Group
ARG DATABASE_URL
ARG MAIL_FROM
ARG NEXT_PUBLIC_WEBAPP_URL
ARG NEXTAUTH_SECRET
ARG NEXTAUTH_URL
ARG SENDGRID_API_KEY
# Fill docker ENV variables with render.com ENV Group - BUILD-TIME
ENV DATABASE_URL=$DATABASE_URL \
MAIL_FROM=$ \
NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL \
NEXTAUTH_SECRET=&NEXTAUTH_SECRET \
NEXTAUTH_URL=$NEXTAUTH_URL \
SENDGRID_API_KEY=$SENDGRID_API_KEY
COPY . /app
RUN npm run build
# No runtime ENV Variables set so far besides ENV
ENV NODE_ENV production
EXPOSE 3000
CMD ["npm", "start"]

View File

@ -1,35 +0,0 @@
import { ReactElement, ReactNode } from "react";
import { NextPage } from "next";
import type { AppProps } from "next/app";
import { SubscriptionProvider } from "@documenso/lib/stripe/providers/subscription-provider";
import "../../../node_modules/placeholder-loading/src/scss/placeholder-loading.scss";
import "../../../node_modules/react-resizable/css/styles.css";
import "../styles/tailwind.css";
import { SessionProvider } from "next-auth/react";
import { Toaster } from "react-hot-toast";
import "react-tooltip/dist/react-tooltip.css";
export { coloredConsole } from "@documenso/lib";
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode;
};
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
export default function App({
Component,
pageProps: { session, initialSubscription, ...pageProps },
}: AppPropsWithLayout) {
const getLayout = Component.getLayout || ((page: any) => page);
return (
<SessionProvider session={session}>
<SubscriptionProvider initialSubscription={initialSubscription}>
<Toaster position="top-center" />
{getLayout(<Component {...pageProps} />)}
</SubscriptionProvider>
</SessionProvider>
);
}

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