Compare commits
250 Commits
chore/remo
...
feat/updat
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e2d1fb148 | |||
| 81f9b2f776 | |||
| 334671ef85 | |||
| a20b1e2f6a | |||
| 572f9d5ad6 | |||
| e49c102e8c | |||
| 2348221b03 | |||
| a0c327cfcf | |||
| ddbeb9e3db | |||
| e8205c1390 | |||
| fbb53fc8c3 | |||
| d7fed5a5dc | |||
| 3285881586 | |||
| 3ff61607a2 | |||
| 2a124b03e9 | |||
| e1aa23bc55 | |||
| 818c5c7ba4 | |||
| 668011d1c7 | |||
| 562fd043a9 | |||
| b4781c011c | |||
| 5c7d3d5503 | |||
| 0f2b6c0ebf | |||
| ab5bdfeae4 | |||
| e5fa2f4490 | |||
| 2d2615447e | |||
| 46465acd73 | |||
| 2dea9ec3e7 | |||
| 584d5bd8ea | |||
| e4b6d42672 | |||
| 109ad190cf | |||
| 78498793fa | |||
| b2e916378d | |||
| a52c19355a | |||
| e67e96333b | |||
| 7aa75b0c64 | |||
| 9b1069f208 | |||
| abe20b8dcf | |||
| 58509c54a9 | |||
| fd545cee0c | |||
| 1f92bffe7d | |||
| 5615627001 | |||
| 78c55875ef | |||
| a3674947b8 | |||
| 63cc57b035 | |||
| d4bc1eb0d1 | |||
| c9f5496acb | |||
| 99481b6144 | |||
| cee147bc9a | |||
| 3a825391b9 | |||
| bb347e4614 | |||
| 68d624ac78 | |||
| 8012d665ae | |||
| fde53e355f | |||
| 8e0a10298e | |||
| 1e5ecd79c2 | |||
| 58f10268e2 | |||
| 43ce76d928 | |||
| 201ba65e1c | |||
| 0d130b17c8 | |||
| 2c90f767fd | |||
| 946b2fe129 | |||
| 2a2dbb65c6 | |||
| ef2300a600 | |||
| eea99ac871 | |||
| f4c7799537 | |||
| 764c6b88c5 | |||
| 989d7a351f | |||
| 78325d9b39 | |||
| 679fb80f9a | |||
| dc49277bf9 | |||
| b3f26055d9 | |||
| 1b276a0469 | |||
| 17fbf2673c | |||
| cdc50ec876 | |||
| 181af24b78 | |||
| 4b13a42731 | |||
| e330e90688 | |||
| f2d3c51651 | |||
| c9c111cdf2 | |||
| c247295131 | |||
| d417255910 | |||
| 677a15327b | |||
| d5238939ad | |||
| 6860726e83 | |||
| a55197fb2d | |||
| 7eed5c7c96 | |||
| 1c629af651 | |||
| 4b8aa3298b | |||
| bcb163693a | |||
| e6e8de62c8 | |||
| 71c7a6ee8c | |||
| ecc8e59c8c | |||
| d0f027c4ea | |||
| 2c667f785c | |||
| 1adf7e183e | |||
| 7771d7acbe | |||
| 58580c7fac | |||
| 4cd56fa98c | |||
| 68020006b4 | |||
| 70cb65d266 | |||
| cef8cad14c | |||
| def8f45f8b | |||
| ca325cc90b | |||
| a1ce6f696a | |||
| 09c7f9dde8 | |||
| 0060b9da8c | |||
| bad88a2a83 | |||
| 96a79b8879 | |||
| 60ef9df721 | |||
| 2d8ca8fea0 | |||
| b411db40da | |||
| 1be0b9e01f | |||
| d41ca8e0e6 | |||
| eaa3e0a303 | |||
| a283c88d7f | |||
| d4659eee07 | |||
| b93e3c0b52 | |||
| 079963cde8 | |||
| 45f447c796 | |||
| 2327b15e0d | |||
| 166cbc150f | |||
| f561ef3cda | |||
| 29bd4cb9c3 | |||
| 1237944b71 | |||
| b331e3c780 | |||
| 7f641e3e73 | |||
| b84f0548d2 | |||
| 0f92534f00 | |||
| 7a489f241a | |||
| f88e529111 | |||
| 47d55a5eab | |||
| 9dcab76cd5 | |||
| c2a9647c90 | |||
| 02424596db | |||
| 776324c875 | |||
| 6f4c280583 | |||
| dfebdfccda | |||
| 5809109c05 | |||
| a6400eb6c9 | |||
| 39958ed22c | |||
| c3d9cac43f | |||
| 74355244a4 | |||
| 7c4ba1b1ea | |||
| f588897531 | |||
| 5629e08f83 | |||
| 37394c054c | |||
| 8be52e2fa3 | |||
| 0d702e9189 | |||
| 425db8fc1f | |||
| 2356f58e7b | |||
| 6c12ed4afc | |||
| d76ee7f33c | |||
| f8534b2c3d | |||
| 9014f01276 | |||
| 71818c0f1f | |||
| 974dc74073 | |||
| b255eb21e5 | |||
| 9a58178ea5 | |||
| 3c36eedfba | |||
| 46dfaa70a3 | |||
| 61da354a48 | |||
| fbb332fb35 | |||
| 7e1cce9155 | |||
| ed4cbe9fa6 | |||
| 599e857a1e | |||
| 581f08c59b | |||
| 24a2e9e6d4 | |||
| e8796a7d86 | |||
| db3f75c42f | |||
| b7c0df67b1 | |||
| e8b5b3b24a | |||
| 00574325b9 | |||
| 99706e0ed6 | |||
| 326743d8a1 | |||
| 3f67b0f27e | |||
| 24036b0f24 | |||
| fbf32404a6 | |||
| 975d52a07e | |||
| f8a193c0f8 | |||
| 9186cb4d7b | |||
| 898f5a629c | |||
| 933076fa3f | |||
| 27edcebef6 | |||
| abc91f7eac | |||
| 5862af3034 | |||
| 35acf05997 | |||
| 2bad1b9f55 | |||
| 73b0dc315e | |||
| 5969f148c8 | |||
| 660f5894a6 | |||
| 77058220a8 | |||
| 6cdba45396 | |||
| 67571158e8 | |||
| 171a5ba4ee | |||
| 525ff21563 | |||
| 863e53a2d5 | |||
| da2033692c | |||
| dbbe17a0a8 | |||
| a2ef9468ae | |||
| 0c145fab0b | |||
| f6e49e3f21 | |||
| 1f027d75d3 | |||
| 1ba7767f8e | |||
| 8220b2f086 | |||
| a74374e39f | |||
| ff957a2f82 | |||
| 6640f0496a | |||
| e47ab838c5 | |||
| 551918ab9b | |||
| 739f3763dd | |||
| 0cdd980a4b | |||
| b43ddcbea2 | |||
| 75feaefbf2 | |||
| de3ebe16ee | |||
| 84a2d3baf6 | |||
| 74180defd1 | |||
| aeeaaf0d8d | |||
| 2b84293c4e | |||
| b38ef6c0a7 | |||
| d524ea77ab | |||
| 17af4d25bd | |||
| 6e095921e6 | |||
| 150c42b246 | |||
| 2524458b0c | |||
| 12c45fb882 | |||
| 118483b6cc | |||
| fd6350b397 | |||
| b3291c65bc | |||
| 4b849e286c | |||
| 7bcc26a987 | |||
| 692722d32e | |||
| e4f06d8e30 | |||
| c799380787 | |||
| 5540fcf0d2 | |||
| d9da09c1e7 | |||
| fe90aa3b7b | |||
| 0c680e0111 | |||
| 8d4df7d3dd | |||
| f1f6e2e40a | |||
| 40274021ba | |||
| 66dfdc5ad0 | |||
| b45978374b | |||
| 81c3e701e2 | |||
| 874d919a6a | |||
| e8559cecd5 | |||
| 3cbc722b94 | |||
| 8844143ff5 | |||
| 7de7624477 | |||
| 7c6b5ac59d | |||
| 9c45ce61b8 |
32
.devcontainer/devcontainer.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "Documenso",
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/base:bullseye",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||||
|
"version": "latest",
|
||||||
|
"enableNonRootDocker": "true",
|
||||||
|
"moby": "true"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/node:1": {}
|
||||||
|
},
|
||||||
|
"onCreateCommand": "./.devcontainer/on-create.sh",
|
||||||
|
"forwardPorts": [3000, 54320, 9000, 2500, 1100],
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"aaron-bond.better-comments",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"mikestead.dotenv",
|
||||||
|
"unifiedjs.vscode-mdx",
|
||||||
|
"GitHub.copilot-chat",
|
||||||
|
"GitHub.copilot-labs",
|
||||||
|
"GitHub.copilot",
|
||||||
|
"GitHub.vscode-pull-request-github",
|
||||||
|
"Prisma.prisma",
|
||||||
|
"VisualStudioExptTeam.vscodeintellicode",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
.devcontainer/on-create.sh
Executable file
@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Start the database and mailserver
|
||||||
|
docker compose -f ./docker/compose-without-app.yml up -d
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Copy the env file
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Source the env file, export the variables
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
# Run the migrations
|
||||||
|
npm run -w @documenso/prisma prisma:migrate-dev
|
||||||
3
.devcontainer/post-start.sh
Executable file
@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
npm run dev
|
||||||
20
.env.example
@ -6,15 +6,29 @@ NEXTAUTH_SECRET="secret"
|
|||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||||
|
|
||||||
# [[APP]]
|
# [[URLS]]
|
||||||
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
|
||||||
|
|
||||||
# [[DATABASE]]
|
# [[DATABASE]]
|
||||||
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||||
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
||||||
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||||
|
|
||||||
|
# [[STORAGE]]
|
||||||
|
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||||
|
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
||||||
|
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
|
||||||
|
NEXT_PRIVATE_UPLOAD_ENDPOINT=
|
||||||
|
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
|
||||||
|
NEXT_PRIVATE_UPLOAD_REGION=
|
||||||
|
# REQUIRED: Defines the bucket to use for the S3 storage transport.
|
||||||
|
NEXT_PRIVATE_UPLOAD_BUCKET=
|
||||||
|
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
|
||||||
|
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=
|
||||||
|
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
|
||||||
|
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
# [[SMTP]]
|
# [[SMTP]]
|
||||||
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
||||||
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
|
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
|
||||||
|
|||||||
@ -5,3 +5,4 @@
|
|||||||
# Statically hosted javascript files
|
# Statically hosted javascript files
|
||||||
apps/*/public/*.js
|
apps/*/public/*.js
|
||||||
apps/*/public/*.cjs
|
apps/*/public/*.cjs
|
||||||
|
scripts/
|
||||||
|
|||||||
6
.github/dependabot.yml
vendored
@ -9,7 +9,7 @@ updates:
|
|||||||
labels:
|
labels:
|
||||||
- "ci dependencies"
|
- "ci dependencies"
|
||||||
- "ci"
|
- "ci"
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 2
|
||||||
|
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/apps/marketing"
|
directory: "/apps/marketing"
|
||||||
@ -19,7 +19,7 @@ updates:
|
|||||||
labels:
|
labels:
|
||||||
- "npm dependencies"
|
- "npm dependencies"
|
||||||
- "frontend"
|
- "frontend"
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 2
|
||||||
|
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/apps/web"
|
directory: "/apps/web"
|
||||||
@ -29,4 +29,4 @@ updates:
|
|||||||
labels:
|
labels:
|
||||||
- "npm dependencies"
|
- "npm dependencies"
|
||||||
- "frontend"
|
- "frontend"
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 2
|
||||||
|
|||||||
6
.github/workflows/ci.yml
vendored
@ -22,12 +22,18 @@ jobs:
|
|||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Copy env
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|||||||
7
.github/workflows/codeql-analysis.yml
vendored
@ -32,7 +32,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Copy env
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
- name: Build Documenso
|
- name: Build Documenso
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
@ -42,4 +45,4 @@ jobs:
|
|||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v2
|
||||||
|
|||||||
147
README.md
@ -1,13 +1,11 @@
|
|||||||
<p align="center" style="margin-top: 120px">
|
<p align="center" style="margin-top: 120px">
|
||||||
<a href="https://github.com/documenso/documenso.com">
|
<a href="https://github.com/documenso/documenso">
|
||||||
<img width="250px" src="https://github.com/documenso/documenso/assets/1309312/cd7823ec-4baa-40b9-be78-4acb3b1c73cb" alt="Documenso Logo">
|
<img width="250px" src="https://github.com/documenso/documenso/assets/1309312/ea8de2d0-8e33-4527-8a5c-40780142a2ed" alt="Documenso Logo">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<h3 align="center">Open Source Signing Infrastructure</h3>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
The DocuSign Open Source Alternative.
|
The Open Source DocuSign Alternative.
|
||||||
<br />
|
<br>
|
||||||
<a href="https://documenso.com"><strong>Learn more »</strong></a>
|
<a href="https://documenso.com"><strong>Learn more »</strong></a>
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
@ -22,39 +20,36 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://documen.so/discord"><img src="https://img.shields.io/badge/Discord-documen.so/discord-%235865F2" alt="Join Documenso on Discord"></a>
|
<a href="https://documen.so/discord"><img src="https://img.shields.io/badge/Discord-documen.so/discord-%235865F2" alt="Join Documenso on Discord"></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/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/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>
|
<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>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# Documenso 0.9 - Developer Preview
|
> 🦺 Documenso 1.0 is deployed to our <a href="https://documen.so/staging" target="_blank">Staging Environment</a>.
|
||||||
|
>
|
||||||
|
> The code can be found on the [feat/refresh](https://github.com/documenso/documenso/tree/feat/refresh) branch.
|
||||||
|
>
|
||||||
|
> The new Version will be released after the current testing phase.
|
||||||
|
|
||||||
|
|
||||||
|
# Join us in testing Documenso 1.0 during [MALFUNCTION MANIA](https://documenso.com/blog/malfunction-mania)
|
||||||
<div>
|
<div>
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
<img style="display: block; height: 120px; width: 24%"
|
||||||
src="https://user-images.githubusercontent.com/1309312/224570645-167128ee-3e39-4578-85d2-5394d9a0379c.png">
|
src="https://github.com/documenso/documenso/assets/1309312/67e08c98-c153-4115-aa2d-77979bb12c94)">
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
<img style="display: block; height: 120px; width: 24%"
|
||||||
src="https://user-images.githubusercontent.com/1309312/224570651-0afd12f8-cfe3-49d1-805e-e495af963d91.png">
|
src="https://github.com/documenso/documenso/assets/1309312/040cfbae-3438-4ca3-87f2-ce52c793dcaf">
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
<img style="display: block; height: 120px; width: 24%"
|
||||||
src="https://user-images.githubusercontent.com/1309312/224570655-328d2279-058d-4a3e-b5c3-5cbd8a1f4e05.png">
|
src="https://github.com/documenso/documenso/assets/1309312/72d445be-41e5-4936-bdba-87ef8e70fa09">
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
<img style="display: block; height: 120px; width: 24%"
|
||||||
src="https://user-images.githubusercontent.com/1309312/224571617-1f3c2811-c1ac-4d7d-b9b0-4ab183731405.png">
|
src="https://github.com/documenso/documenso/assets/1309312/d7b86c0f-a755-4476-a022-a608db2c4633">
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
<img style="display: block; height: 120px; width: 24%"
|
||||||
src="https://user-images.githubusercontent.com/1309312/224570322-b2c76ea8-7482-4043-ad97-f1221220c591.png">
|
src=https://github.com/documenso/documenso/assets/1309312/c0f55116-ab82-433f-a266-f3fc8571d69f">
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
|
||||||
src="https://user-images.githubusercontent.com/1309312/224570325-a8055f24-9826-4a23-b116-4fbb0577581a.png">
|
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
|
||||||
src="https://user-images.githubusercontent.com/1309312/224570318-f724bbd9-c394-4bdc-bace-2d78af92de44.png">
|
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
|
||||||
src="https://user-images.githubusercontent.com/1309312/224571539-f019b860-f613-4b20-86e8-4437c5784265.png">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **Note**
|
|
||||||
> This project is currently under community review and will publish it's first production release soon™.
|
|
||||||
|
|
||||||
## About this project
|
## About this project
|
||||||
|
|
||||||
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.
|
Signing documents digitally is fast and easy and should be the 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
|
## Recognition
|
||||||
|
|
||||||
@ -63,18 +58,28 @@ Signing documents digitally is fast, easy and should be best practice for every
|
|||||||
|
|
||||||
## Community and Next Steps 🎯
|
## 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:
|
We're currently working on a redesign of the application, including a revamp of the codebase so Documenso can be more intuitive to use and robust to develop upon.
|
||||||
|
|
||||||
- Check out the first source code release in this repository and test it
|
- 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)
|
- Tell us what you think in the current [Discussions](https://github.com/documenso/documenso/discussions)
|
||||||
- Join the [Slack Channel](https://documen.so/slack) for any questions and getting to know to other community members
|
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know to other community members
|
||||||
- ⭐ the repository to help us raise awareness
|
- ⭐ the repository to help us raise awareness
|
||||||
- Spread the word on Twitter, that Documenso is working towards a more open signing tool
|
- 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
|
- Fix or create [issues](https://github.com/documenso/documenso/issues), that are needed for the first production release
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
- To contribute please see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md).
|
- To contribute, please see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md).
|
||||||
|
|
||||||
|
## Contact us
|
||||||
|
|
||||||
|
Contact us if you are interested in our Enterprise plan for large organizations that need extra flexibility and control.
|
||||||
|
|
||||||
|
<a href="https://cal.com/timurercan/enterprise-customers?utm_source=banner&utm_campaign=oss"><img alt="Book us with Cal.com" src="https://cal.com/book-with-cal-dark.svg" /></a>
|
||||||
|
|
||||||
|
## Activity
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
# Tech
|
# Tech
|
||||||
|
|
||||||
@ -89,10 +94,6 @@ Documenso is built using awesome open source tech including:
|
|||||||
- [Node SignPDF (Digital Signature)](https://github.com/vbuch/node-signpdf)
|
- [Node SignPDF (Digital Signature)](https://github.com/vbuch/node-signpdf)
|
||||||
- [React-PDF for viewing PDFs](https://github.com/wojtekmaj/react-pdf)
|
- [React-PDF for viewing PDFs](https://github.com/wojtekmaj/react-pdf)
|
||||||
- [PDF-Lib for PDF manipulation](https://github.com/Hopding/pdf-lib)
|
- [PDF-Lib for PDF manipulation](https://github.com/Hopding/pdf-lib)
|
||||||
- [Zod for schema declaration and validation](https://zod.dev/)
|
|
||||||
- [Lucide React for icons in React app](https://lucide.dev/)
|
|
||||||
- [Framer Motion for motion library](https://www.framer.com/motion/)
|
|
||||||
- [Radix UI for component library](https://www.radix-ui.com/)
|
|
||||||
- Check out `/package.json` and `/apps/web/package.json` for more
|
- Check out `/package.json` and `/apps/web/package.json` for more
|
||||||
- Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned.
|
- Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned.
|
||||||
|
|
||||||
@ -135,37 +136,47 @@ Your database will also be available on port `54320`. You can connect to it usin
|
|||||||
|
|
||||||
## Developer Setup
|
## Developer Setup
|
||||||
|
|
||||||
|
### Manual Setup
|
||||||
|
|
||||||
Follow these steps to setup documenso on you local machine:
|
Follow these steps to setup documenso on you local machine:
|
||||||
|
|
||||||
- [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
- [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/documenso/documenso
|
git clone https://github.com/documenso/documenso
|
||||||
```
|
```
|
||||||
- Run <code>npm i</code> in root directory
|
- Run `npm i` in root directory
|
||||||
- Rename <code>.env.example</code> to <code>.env</code>
|
- Rename `.env.example` to `.env`
|
||||||
- Set DATABASE_URL value in .env file
|
- Set DATABASE_URL value in .env file
|
||||||
- You can use the provided test database url (may be wiped at any point)
|
- You can use the provided test database url (may be wiped at any point)
|
||||||
- Or setup a local postgres sql instance (recommended)
|
- Or setup a local postgres sql instance (recommended)
|
||||||
- Create the database scheme by running <code>db-migrate:dev</code>
|
- Create the database scheme by running `db-migrate:dev`
|
||||||
- Setup your mail provider
|
- Setup your mail provider
|
||||||
- Set <code>SENDGRID_API_KEY</code> value in .env file
|
- Set `SENDGRID_API_KEY` value in .env file
|
||||||
- You need a SendGrid account, which you can create [here](https://signup.sendgrid.com/).
|
- You need a SendGrid account, which you can create [here](https://signup.sendgrid.com/).
|
||||||
- Documenso uses [Nodemailer](https://nodemailer.com/about/) so you can easily use your own SMTP server by setting the <code>SMTP\_\* variables</code> in your .env
|
- Documenso uses [Nodemailer](https://nodemailer.com/about/) so you can easily use your own SMTP server by setting the `SMTP
|
||||||
- Run <code>npm run dev</code> root directory to start
|
\_
|
||||||
|
* variables` in your .env
|
||||||
|
- Run `npm run dev` root directory to start
|
||||||
- Register a new user at http://localhost:3000/signup
|
- Register a new user at http://localhost:3000/signup
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- Optional: Seed the database using <code>npm run db-seed</code> to create a test user and document
|
- Optional: Seed the database using `npm run db-seed` to create a test user and document
|
||||||
- Optional: Upload and sign <code>apps/web/resources/example.pdf</code> manually to test your setup
|
- Optional: Upload and sign `apps/web/resources/example.pdf` manually to test your setup
|
||||||
|
|
||||||
- Optional: Create your own signing certificate
|
- Optional: Create your own signing certificate
|
||||||
- A demo certificate is provided in `/app/web/resources/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)**.
|
- 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)**.
|
||||||
|
|
||||||
|
### Run in Gitpod
|
||||||
|
|
||||||
|
- Click below to launch a ready-to-use Gitpod workspace in your browser.
|
||||||
|
|
||||||
|
[](https://gitpod.io/#https://github.com/documenso/documenso)
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
- If you pull the newest version from main, using <code>git pull</code>, it may be necessary to regenerate your database client
|
- If you pull the newest version from main, using `git pull`, it may be necessary to regenerate your database client
|
||||||
- You can do this by running the generate command in `/packages/prisma`:
|
- You can do this by running the generate command in `/packages/prisma`:
|
||||||
```sh
|
```sh
|
||||||
npx prisma generate
|
npx prisma generate
|
||||||
@ -176,16 +187,22 @@ Follow these steps to setup documenso on you local machine:
|
|||||||
|
|
||||||
For the digital signature of your documents you need a signing certificate in .p12 format (public and private key). You can buy one (not recommended for dev) or use the steps to create a self-signed one:
|
For the digital signature of your documents you need a signing certificate in .p12 format (public and private key). You can buy one (not recommended for dev) or use the steps to create a self-signed one:
|
||||||
|
|
||||||
1. Generate a private key using the OpenSSL command. You can run the following command to generate a 2048-bit RSA key:\
|
1. Generate a private key using the OpenSSL command. You can run the following command to generate a 2048-bit RSA key:
|
||||||
<code>openssl genrsa -out private.key 2048</code>
|
|
||||||
|
`openssl genrsa -out private.key 2048`
|
||||||
|
|
||||||
|
2. Generate a self-signed certificate using the private key. You can run the following command to generate a self-signed certificate:
|
||||||
|
|
||||||
|
`openssl req -new -x509 -key private.key -out certificate.crt -days 365`
|
||||||
|
|
||||||
2. Generate a self-signed certificate using the private key. You can run the following command to generate a self-signed certificate:\
|
|
||||||
<code>openssl req -new -x509 -key private.key -out certificate.crt -days 365</code> \
|
|
||||||
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The -days parameter sets the number of days for which the certificate is valid.
|
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The -days parameter sets the number of days for which the certificate is valid.
|
||||||
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>
|
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following command to do this:
|
||||||
|
|
||||||
|
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
|
||||||
|
|
||||||
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**)
|
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/resources/certificate.p12</code>
|
5. Place the certificate `/apps/web/resources/certificate.p12`
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
|
|
||||||
@ -193,16 +210,42 @@ For the digital signature of your documents you need a signing certificate in .p
|
|||||||
|
|
||||||
Want to create a production ready docker image? Follow these steps:
|
Want to create a production ready docker image? Follow these steps:
|
||||||
|
|
||||||
- Run `./docker/build.sh` in the root directory.
|
- cd into `docker` directory
|
||||||
- Publish the image to your docker registry of choice.
|
- Make `build.sh` executable by running `chmod +x build.sh`
|
||||||
|
- Run `./build.sh` to start building the docker image.
|
||||||
|
- Publish the image to your docker registry of choice (or) If you prefer running the image from local, run the below command
|
||||||
|
|
||||||
# Deploying - Coming Soon™
|
```
|
||||||
|
docker run -d --restart=unless-stopped -p 3000:3000 -v documenso:/app/data --name documenso documenso:latest
|
||||||
|
```
|
||||||
|
|
||||||
- Docker support
|
Command Breakdown:
|
||||||
- One-Click-Deploy on Render.com Deploy
|
- `-d` - Let's you run the container in background
|
||||||
|
- `-p` - Passes down which ports to use. First half is the host port, Second half is the app port. You can change the first half anything you want and reverse proxy to that port.
|
||||||
|
- `-v` - Volume let's you persist the data
|
||||||
|
- `--name` - Name of the container
|
||||||
|
- `documenso:latest` - Image you have built
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
|
||||||
|
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
|
||||||
|
|
||||||
|
## Railway
|
||||||
|
|
||||||
|
[](https://railway.app/template/DjrRRX)
|
||||||
|
|
||||||
|
## Render
|
||||||
|
|
||||||
|
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
||||||
|
|
||||||
# Troubleshooting
|
# Troubleshooting
|
||||||
|
|
||||||
|
## I'm not receiving any emails when using the developer quickstart
|
||||||
|
|
||||||
|
When using the developer quickstart an [Inbucket](https://inbucket.org/) server will be spun up in a docker container that will store all outgoing email locally for you to view.
|
||||||
|
|
||||||
|
The Web UI can be found at http://localhost:9000 while the SMTP port will be on localhost:2500.
|
||||||
|
|
||||||
## Support IPv6
|
## 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
|
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
|
||||||
|
|||||||
@ -1,98 +1,98 @@
|
|||||||
---
|
---
|
||||||
title: 'Building Documenso — Part 1: Certificates'
|
title: 'Building Documenso — Part 1: Certificates'
|
||||||
description: In today's fast-paced world, productivity and efficiency are crucial for success, both in personal and professional endeavors. We all strive to make the most of our time and energy to achieve our goals effectively. However, it's not always easy to stay on track and maintain peak performance. In this blog post, we'll explore 10 valuable tips to help you boost productivity and efficiency in your daily life.
|
description: This is the first part of the new Building Documenso series, where I describe the challenges and design choices that we make while building the world’s most open signing platform.
|
||||||
authorName: 'Timur Ercan'
|
authorName: 'Timur Ercan'
|
||||||
authorImage: '/blog/blog-author-timur.jpeg'
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
authorRole: 'Co-Founder'
|
authorRole: 'Co-Founder'
|
||||||
date: 2023-06-23
|
date: 2023-06-23
|
||||||
tags:
|
tags:
|
||||||
- Open Source
|
- Open Source
|
||||||
- Document Signature
|
- Document Signature
|
||||||
- Certificates
|
- Certificates
|
||||||
- Signing
|
- Signing
|
||||||
---
|
---
|
||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
<MdxNextImage
|
<MdxNextImage
|
||||||
src="/blog/blog-banner-building-documenso.webp"
|
src="/blog/blog-banner-building-documenso.webp"
|
||||||
width="1200"
|
width="1200"
|
||||||
height="675"
|
height="675"
|
||||||
alt="Building Documenso blog banner"
|
alt="Building Documenso blog banner"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<figcaption className="text-center">
|
<figcaption className="text-center">
|
||||||
What actually is a signature?
|
What actually is a signature?
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
> Disclaimer: I’m not a lawyer and this isn’t legal advice. We plan to publish a much more specific framework on the topic of signature validity.
|
> Disclaimer: I’m not a lawyer and this isn’t legal advice. We plan to publish a much more specific framework on the topic of signature validity.
|
||||||
|
|
||||||
This is the first installment of the new Building Documenso series, where I describe the challenges and design choices that we make while building the world’s most open signing platform.
|
This is the first installment of the new Building Documenso series, where I describe the challenges and design choices that we make while building the world’s most open signing platform.
|
||||||
|
|
||||||
As you may have heard, we launched the community-reviewed <a href="https://github.com/documenso/documenso" target="_blank">version 0.9 of Documenso on GitHub</a> recently and it’s now available through the early adopter’s plan. One of the most fundamental choices we had to make on this first release, was the choice of certificate. While it’s interesting to know what we opted for, this shall also serve as a guide for everyone facing the same choice for self-hosting Documenso.
|
As you may have heard, we launched the community-reviewed <a href="https://github.com/documenso/documenso" target="_blank">version 0.9 of Documenso on GitHub</a> recently and it’s now available through the early adopter’s plan. One of the most fundamental choices we had to make on this first release, was the choice of certificate. While it’s interesting to know what we opted for, this shall also serve as a guide for everyone facing the same choice for self-hosting Documenso.
|
||||||
|
|
||||||
> Question: Why do I need a document signing certificate to self-host?
|
> Question: Why do I need a document signing certificate to self-host?
|
||||||
>
|
>
|
||||||
> Short Answer: Inserting the images of a signature into the document is only part of the signing process.
|
> Short Answer: Inserting the images of a signature into the document is only part of the signing process.
|
||||||
|
|
||||||
To have an actual digitally signed document you need a document signing certificate that is used to create the digital signature that is inserted into the document, alongside the visible one¹.
|
To have an actual digitally signed document you need a document signing certificate that is used to create the digital signature that is inserted into the document, alongside the visible one¹.
|
||||||
|
|
||||||
When hosting a signature service yourself, as we do, there are four main choices for handling the certificate: Not using a certificate, creating your own, buying a trusted certificate, and becoming and trusted service provider to issue your own trusted certificate.
|
When hosting a signature service yourself, as we do, there are four main choices for handling the certificate: Not using a certificate, creating your own, buying a trusted certificate, and becoming and trusted service provider to issue your own trusted certificate.
|
||||||
|
|
||||||
## 1\. No Certificate
|
## 1\. No Certificate
|
||||||
|
|
||||||
A lot of signing services actually don’t employ actual digital signatures besides the inserted image. The only insert and image of the signatures into the document you sign. This can be done and is legally acceptable in many cases. This option isn’t directly supported by Documenso without changing the code.
|
A lot of signing services actually don’t employ actual digital signatures besides the inserted image. The only insert and image of the signatures into the document you sign. This can be done and is legally acceptable in many cases. This option isn’t directly supported by Documenso without changing the code.
|
||||||
|
|
||||||
## 2\. Create your own
|
## 2\. Create your own
|
||||||
|
|
||||||
Since the cryptography behind certificates is freely available as open source you could generate your own using OpenSSL for example. Since it’s hardly more work than option 1 (using Documenso at least), this would be my minimum effort recommendation. Having a self-created (“self-signed”) certificate doesn’t add much in terms of regulation but it guarantees the document’s integrity, meaning no changes have been made after signing². What this doesn’t give you, is the famous green checkmark in Adobe Acrobat. Why? Because you aren’t on the list of providers Adobe “trusts”.³
|
Since the cryptography behind certificates is freely available as open source you could generate your own using OpenSSL for example. Since it’s hardly more work than option 1 (using Documenso at least), this would be my minimum effort recommendation. Having a self-created (“self-signed”) certificate doesn’t add much in terms of regulation but it guarantees the document’s integrity, meaning no changes have been made after signing². What this doesn’t give you, is the famous green checkmark in Adobe Acrobat. Why? Because you aren’t on the list of providers Adobe “trusts”.³
|
||||||
|
|
||||||
## 3\. Buy a “trusted” certificate.
|
## 3\. Buy a “trusted” certificate.
|
||||||
|
|
||||||
There are Certificate Authorities (CAs) that can sell you a certificate⁴. The service they provide is, that they validate your name (personal certificates) or your organization’s name (corporate certificate) before creating your certificate for you, just like you did in option 2. The difference is, that they are listed on the previously mentioned trust lists (e.g. Adobe’s) and thus the resulting signatures get a nice, green checkmark in Adobe Reader⁵
|
There are Certificate Authorities (CAs) that can sell you a certificate⁴. The service they provide is, that they validate your name (personal certificates) or your organization’s name (corporate certificate) before creating your certificate for you, just like you did in option 2. The difference is, that they are listed on the previously mentioned trust lists (e.g. Adobe’s) and thus the resulting signatures get a nice, green checkmark in Adobe Reader⁵
|
||||||
|
|
||||||
## 4\. Becoming a Trusted Certificate Authority (CA) yourself and create your own certificate
|
## 4\. Becoming a Trusted Certificate Authority (CA) yourself and create your own certificate
|
||||||
|
|
||||||
This option is an incredibly complex endeavour, requiring a lot of effort and skill. It can be done, as there are multiple CAs around the world. Is it worth the effort? That depends a lot on what you’re trying to accomplish.
|
This option is an incredibly complex endeavour, requiring a lot of effort and skill. It can be done, as there are multiple CAs around the world. Is it worth the effort? That depends a lot on what you’re trying to accomplish.
|
||||||
|
|
||||||
<center>. . .</center>
|
<center>. . .</center>
|
||||||
|
|
||||||
## What we did
|
## What we did
|
||||||
|
|
||||||
Having briefly introduced the options, here is what we did: Since we aim to raise the bar on digital signature proliferation and trust, we opted to buy an “Advanced Personal Certificates for Companies/Organisations” from WiseKey. Thus, documents signed with Documenso’s hosted version look like this:
|
Having briefly introduced the options, here is what we did: Since we aim to raise the bar on digital signature proliferation and trust, we opted to buy an “Advanced Personal Certificates for Companies/Organisations” from WiseKey. Thus, documents signed with Documenso’s hosted version look like this:
|
||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
<MdxNextImage
|
<MdxNextImage
|
||||||
src="/blog/blog-fig-building-documenso.webp"
|
src="/blog/blog-fig-building-documenso.webp"
|
||||||
width="1262"
|
width="1262"
|
||||||
height="481"
|
height="481"
|
||||||
alt="Figure 1"
|
alt="Figure 1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<figcaption className="text-center">The famous green checkmark: Signed by hosted Documenso</figcaption>
|
<figcaption className="text-center">The famous green checkmark: Signed by hosted Documenso</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
There weren’t any deeper reasons we choose WiseKey, other than they offered what we needed and there wasn’t any reason to look much further. While I didn’t map the entire certificate market offering (yet), I’m pretty sure something similar could be found elsewhere. While we opted for option 3, choosing option 2 might be perfectly reasonable considering your use case.⁶
|
There weren’t any deeper reasons we choose WiseKey, other than they offered what we needed and there wasn’t any reason to look much further. While I didn’t map the entire certificate market offering (yet), I’m pretty sure something similar could be found elsewhere. While we opted for option 3, choosing option 2 might be perfectly reasonable considering your use case.⁶
|
||||||
|
|
||||||
> While this is our setup, for now, we have a bigger plan for this topic. While globally trusted SSL Certificates have been available for free, courtesy of Let’s Encrypt, for a while now, there is no such thing as document signing. And there should be. Not having free and trusted infrastructure for signing is blocking a completely new generation of signing products from being created. This is why we’ll start working on option 4 when the time is right.
|
> While this is our setup, for now, we have a bigger plan for this topic. While globally trusted SSL Certificates have been available for free, courtesy of Let’s Encrypt, for a while now, there is no such thing as document signing. And there should be. Not having free and trusted infrastructure for signing is blocking a completely new generation of signing products from being created. This is why we’ll start working on option 4 when the time is right.
|
||||||
|
|
||||||
Do you have questions or thoughts about this? As always, let me know in the comments, on <a href="http://twitter.com/eltimuro" target="_blank">twitter.com/eltimuro</a>
|
Do you have questions or thoughts about this? As always, let me know in the comments, on <a href="http://twitter.com/eltimuro" target="_blank">twitter.com/eltimuro</a>
|
||||||
or directly: <a href="https://documen.so/timur" target="_blank">documen.so/timur</a>
|
or directly: <a href="https://documen.so/timur" target="_blank">documen.so/timur</a>
|
||||||
|
|
||||||
Join the self-hoster community here: <a href="https://documenso.slack.com/" target="_blank">https://documenso.slack.com/</a>
|
Join the self-hoster community here: <a href="https://documen.so/discord" target="_blank">https://documen.so/discord</a>
|
||||||
|
|
||||||
Best from Hamburg
|
Best from Hamburg
|
||||||
|
|
||||||
Timur
|
Timur
|
||||||
|
|
||||||
\[1\] There are different approaches to signing a document. For the sake of simplicity, here we talk about a document with X inserted signature images, that is afterward signed once the by signing service, i.e. Documenso. If each visual signature should have its own digital one (e.g. QES — eIDAS Level 3), the case is a bit more complex.
|
\[1\] There are different approaches to signing a document. For the sake of simplicity, here we talk about a document with X inserted signature images, that is afterward signed once the by signing service, i.e. Documenso. If each visual signature should have its own digital one (e.g. QES — eIDAS Level 3), the case is a bit more complex.
|
||||||
|
|
||||||
\[2\] Of course, the signing service provider technically can change and resign the document, especially in the case mentioned in \[1\]. This can be countered by requiring actual digital signatures from each signer, that are bound to their identity/ account. Creating a completely trustless system in the context however is extremely hard to do and not the most pressing business need for the industry at this point, in my opinion. Though, this would be nice.
|
\[2\] Of course, the signing service provider technically can change and resign the document, especially in the case mentioned in \[1\]. This can be countered by requiring actual digital signatures from each signer, that are bound to their identity/ account. Creating a completely trustless system in the context however is extremely hard to do and not the most pressing business need for the industry at this point, in my opinion. Though, this would be nice.
|
||||||
|
|
||||||
\[3\] Adobe, like the EU, has a list of organizations they trust. The Adobe green checkmark is powered by the Adobe trust list, if you want to be trusted by EU standards here: <a href="https://ec.europa.eu/digital-building-blocks/DSS/webapp-demo/validation" target="_blank">https://ec.europa.eu/digital-building-blocks/DSS/webapp-demo/validation</a>, you need to be on the EU trust list. Getting on each list is possible, though the latter is much more work.
|
\[3\] Adobe, like the EU, has a list of organizations they trust. The Adobe green checkmark is powered by the Adobe trust list, if you want to be trusted by EU standards here: <a href="https://ec.europa.eu/digital-building-blocks/DSS/webapp-demo/validation" target="_blank">https://ec.europa.eu/digital-building-blocks/DSS/webapp-demo/validation</a>, you need to be on the EU trust list. Getting on each list is possible, though the latter is much more work.
|
||||||
|
|
||||||
\[4\] Technically, they sign your certificate creation request (created by you), containing your info with their certificate (which is trusted), making your certificate trusted. This way, everything you sign with your certificate is seen as trusted. They created their certificate just like you, the difference is they are on the lists, mentioned in \[3\]
|
\[4\] Technically, they sign your certificate creation request (created by you), containing your info with their certificate (which is trusted), making your certificate trusted. This way, everything you sign with your certificate is seen as trusted. They created their certificate just like you, the difference is they are on the lists, mentioned in \[3\]
|
||||||
|
|
||||||
\[5\] Why does Adobe get to say, what is trusted? They simply happen to have the most used pdf viewer. And since everyone checks there, whom they consider trusted carries weight. If it should be like this, is a different matter.
|
\[5\] Why does Adobe get to say, what is trusted? They simply happen to have the most used pdf viewer. And since everyone checks there, whom they consider trusted carries weight. If it should be like this, is a different matter.
|
||||||
|
|
||||||
\[6\] Self-Signed signatures, even purely visual signatures, are fully legally binding. Why you use changes mainly your confidence in the signature and the burden of proof. Also, some industries require a certain level of signatures e.g. retail loans (QES/ eIDAS Level 3 in the EU).
|
\[6\] Self-Signed signatures, even purely visual signatures, are fully legally binding. Why you use changes mainly your confidence in the signature and the burden of proof. Also, some industries require a certain level of signatures e.g. retail loans (QES/ eIDAS Level 3 in the EU).
|
||||||
|
|||||||
@ -0,0 +1,198 @@
|
|||||||
|
---
|
||||||
|
title: 'Deploying Documenso with Vercel, Supabase and Resend'
|
||||||
|
description: This is the first part of the new Building Documenso series, where I describe the challenges and design choices that we make while building the world’s most open signing platform.
|
||||||
|
authorName: 'Ephraim Atta-Duncan'
|
||||||
|
authorImage: '/blog/blog-author-duncan.jpeg'
|
||||||
|
authorRole: 'Software Engineer Intern'
|
||||||
|
date: 2023-09-08
|
||||||
|
tags:
|
||||||
|
- Open Source
|
||||||
|
- Self Hosting
|
||||||
|
- Tutorial
|
||||||
|
---
|
||||||
|
|
||||||
|
In this article, we'll walk you through how to deploy and self-host Documenso using Vercel, Supabase, and Resend.
|
||||||
|
|
||||||
|
You'll learn:
|
||||||
|
|
||||||
|
- How to set up a Postgres database using Supabase,
|
||||||
|
- How to install SMTP with Resend,
|
||||||
|
- How to deploy your project with Vercel.
|
||||||
|
|
||||||
|
If you don't know what [Documenso](https://documenso.com/) is, it's an open-source alternative to DocuSign, with the mission to create an open signing infrastructure while embracing openness, cooperation, and transparency.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before we start, make sure you have a [GitHub](https://github.com/signup) account. You also need [Node.js](https://nodejs.org/en) and [npm](https://www.npmjs.com/) installed on your local machine (note: you also have the option to host it on a cloud environment using Gitpod for example; that would be another post). If you need accounts on Vercel, Supabase, and Resend, create them by visiting the [Vercel](https://vercel.com/), [Supabase](https://supabase.com/), and [Resend](https://resend.com/) websites.
|
||||||
|
|
||||||
|
Checklist:
|
||||||
|
|
||||||
|
- [ ] Have a GitHub account
|
||||||
|
- [ ] Install Node.js
|
||||||
|
- [ ] Install npm
|
||||||
|
- [ ] Have a Vercel account
|
||||||
|
- [ ] Have a Supabase account
|
||||||
|
- [ ] Have a Resend account
|
||||||
|
|
||||||
|
## Step-by-Step guide to deploying Documenso with Vercel, Supabase, and Resend
|
||||||
|
|
||||||
|
To deploy Documenso, we'll take the following steps:
|
||||||
|
|
||||||
|
1. Fork the Documenso repository
|
||||||
|
2. Clone the forked repository and install dependencies
|
||||||
|
3. Create a new project on Supabase
|
||||||
|
4. Copy the Supabase Postgres database connection URL
|
||||||
|
5. Create a `.env` file
|
||||||
|
6. Run the migration on the Supabase Postgres Database
|
||||||
|
7. Get your SMTP Keys on Resend
|
||||||
|
8. Create a new project on Vercel
|
||||||
|
9. Add Environment Variables to your Vercel project
|
||||||
|
|
||||||
|
So, you're ready? Let’s dive in!
|
||||||
|
|
||||||
|
### Step 1: Fork the Documenso repository
|
||||||
|
|
||||||
|
Start by creating a fork of Documenso on GitHub. You can do this by visiting the [Documenso repository](https://github.com/documenso/documenso) and clicking on the 'Fork' button. (Also, star the repo!)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Choose your GitHub profile as the owner and click on 'Create fork' to create a fork of the repo.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 2: Clone the forked repository and install dependencies
|
||||||
|
|
||||||
|
Clone the forked repository to your local machine in any directory of your choice. Open your terminal and enter the following commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repo using Github CLI
|
||||||
|
gh repo clone [your_github_username]/documenso
|
||||||
|
|
||||||
|
# Clone the repo using Git
|
||||||
|
git clone <https://github.com/[your_github_username]/documenso.git>
|
||||||
|
```
|
||||||
|
|
||||||
|
You can now navigate into the directory and install the project’s dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd documenso
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Create a new project on Supabase
|
||||||
|
|
||||||
|
Now, let's set up the database.
|
||||||
|
|
||||||
|
If you haven't already, create a new project on Supabase. This will automatically create a new Postgres database for you.
|
||||||
|
|
||||||
|
On your Supabase dashboard, click the '**New project**' button and choose your organization.
|
||||||
|
|
||||||
|
On the '**Create a new project**' page, set a database name of **documenso** and a secure password for your database. Choose a region closer to you, a pricing plan, and click on '**Create new project**' to create your project.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 4: Copy the Supabase Postgres database connection URL
|
||||||
|
|
||||||
|
In your project, click the '**Settings**' icon at the bottom left.
|
||||||
|
|
||||||
|
Under the '**Project Settings**' section, click '**Database**' and scroll down to the '**Connection string**' section. Copy the '**URI**' and update it with the password you chose in the previous step.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 5: Create a `.env` file
|
||||||
|
|
||||||
|
Create a `.env` file in the root of your project by copying the contents of the `.env.example` file.
|
||||||
|
|
||||||
|
Add the connection string you copied from your Supabase dashboard to the `DATABASE_URL` variable in the `.env` file.
|
||||||
|
|
||||||
|
The `.env` should look like this:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Run the migration on the Supabase Postgres Database
|
||||||
|
|
||||||
|
Run the migration on the Supabase Postgres Database using the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7: Get your SMTP Keys on Resend
|
||||||
|
|
||||||
|
So, you've just cloned Documenso, installed dependencies on your local machine, and set your database using Supabase. Now, SMTP is missing. Emails won't go out! Let's fix it with Resend.
|
||||||
|
|
||||||
|
In the **[Resend](https://resend.com/)** dashboard, click 'Add API Key' to create a key for Resend SMTP.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Next, add and verify your domain in the '**Domains**' section on the sidebar. This will allow you to send emails from any address associated with your domain.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
You can update your `.env` file with the following:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
SMTP_MAIL_HOST = 'smtp.resend.com';
|
||||||
|
SMTP_MAIL_PORT = '25';
|
||||||
|
SMTP_MAIL_USER = 'resend';
|
||||||
|
SMTP_MAIL_PASSWORD = 'YOUR_RESEND_API_KEY';
|
||||||
|
MAIL_FROM = 'noreply@[YOUR_DOMAIN]';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Create a new project on Vercel
|
||||||
|
|
||||||
|
You set the database with Supabase and are SMTP-ready with Resend. Almost there! The next step is to deploy the project — we'll use Vercel for that.
|
||||||
|
|
||||||
|
On your Vercel dashboard, create a new project using the forked project from your GitHub repositories. Select the project among the options and click '**Import**' to start running Documenso.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 9: Add Environment Variables to your Vercel project
|
||||||
|
|
||||||
|
In the '**Configure Project**' page, adding the required Environmental Variables is essential to ensure the application deploys without any errors.
|
||||||
|
|
||||||
|
Specifically, for the `NEXT_PUBLIC_WEBAPP_URL` and `NEXTAUTH_URL` variables, you must add `.vercel.app` to your Project Name. This will form the deployment URL, which will be in the format: `https://[project_name].vercel.app`.
|
||||||
|
|
||||||
|
For example, in my case, the deployment URL is `https://documenso-supabase-web.vercel.app`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This is a sample `.env` to deploy. Copy and paste it to auto-populate the fields and click ‘**Deploy.’** Now, you only need to wait for your project to deploy. You’re going live — enjoy!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL='postgresql://postgres:typeinastrongpassword@db.njuigobjlbteahssqbtw.supabase.co:5432/postgres'
|
||||||
|
|
||||||
|
NEXT_PUBLIC_WEBAPP_URL='https://documenso-supabase-web.vercel.app'
|
||||||
|
NEXTAUTH_SECRET='something gibrish to encrypt your jwt tokens'
|
||||||
|
NEXTAUTH_URL='https://documenso-supabase-web.vercel.app'
|
||||||
|
|
||||||
|
# Get a Sendgrid Api key here: <https://signup.sendgrid.com>
|
||||||
|
SENDGRID_API_KEY=''
|
||||||
|
|
||||||
|
# Set SMTP credentials to use SMTP instead of the Sendgrid API.
|
||||||
|
SMTP_MAIL_HOST='smtp.resend.com'
|
||||||
|
SMTP_MAIL_PORT='25'
|
||||||
|
SMTP_MAIL_USER='resend'
|
||||||
|
SMTP_MAIL_PASSWORD='YOUR_RESEND_API_KEY'
|
||||||
|
MAIL_FROM='noreply@[YOUR_DOMAIN]'
|
||||||
|
|
||||||
|
NEXT_PUBLIC_ALLOW_SIGNUP=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wrapping up
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Congratulations! 🎉 You've successfully deployed Documenso using Vercel, Supabase, and Resend. You're now ready to create and sign your own documents with your self-hosted Documenso!
|
||||||
|
|
||||||
|
In this step-by-step guide, you learned how to:
|
||||||
|
|
||||||
|
- set up a Postgres database using Supabase,
|
||||||
|
- install SMTP with Resend,
|
||||||
|
- deploy your project with Vercel.
|
||||||
|
|
||||||
|
Over to you! How was the tutorial? If you enjoyed it, [please do share](https://twitter.com/documenso/status/1700141802693480482)! And if you have any questions or comments, please reach out to me on [Twitter / X](https://twitter.com/EphraimDuncan_) (DM open) or [Discord](https://documen.so/discord).
|
||||||
|
|
||||||
|
We're building an open-source alternative to DocuSign and welcome every contribution. Head over to the GitHub repository and [leave us a Star](https://github.com/documenso/documenso)!
|
||||||
54
apps/marketing/content/blog/malfunction-mania.mdx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
title: Announcing Malfunction Mania
|
||||||
|
description: Launch Week Day 2 Y'all! We're getting ready to release Documenso 1.0! Join in on the fun of making sure the open-source alternative to DocuSign is on point. We're calling a 'MALFUNCTION MANIA.'
|
||||||
|
authorName: 'Timur Ercan'
|
||||||
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2023-09-26
|
||||||
|
tags:
|
||||||
|
- Testing
|
||||||
|
- Rewrite
|
||||||
|
- Bounties
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/mm.png"
|
||||||
|
width="1260"
|
||||||
|
height="630"
|
||||||
|
alt="Malfunctioning Documenso Logo inbroken colors"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
We're calling a MALFUNCTION MANIA! 🚨
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
> TLDR; Documenso 1.0 is in the [staging environment](https://documen.so/staging). Go check it out.
|
||||||
|
|
||||||
|
It's been a minute since Lucas proclaimed, ["We're doing a rewrite"](https://documenso.com/blog/why-were-doing-a-rewrite), and many of you have been asking when the new version will be available. I'm happy to say that the wait has come to an end. The work on Documenso 1.0 has reached a level we feel comfortable going into the next phase with. We had a lot of community feedback, contributions, and moral support to get us this far, which is why we're excited to announce the most extensive community project yet:
|
||||||
|
|
||||||
|
As Documenso 1.0 just hit the staging environment, we're calling a MALFUNCTION MANIA. An enormous, public testing phase, where we invite everyone to try out the new version, hunt down, report, and fix any malfunctions (aka bugs), and give feedback before release. Malfunction Mania will happen alongside our internal testing, and by combining the two, we want to ensure the best possible release we can have for Version 1.0. We know many of you have been eager to contribute; this is your chance (the first of many to come).
|
||||||
|
|
||||||
|
## As part of Malfunction Mania, we're offering special bug bounties
|
||||||
|
|
||||||
|
- We award $25 - $100 per report/ issue/ fix, depending on the severity and if the problem is already known
|
||||||
|
- Bounties will be awarded for fixing reported or other critical issues via accepted Pull Requests (PR)
|
||||||
|
- Just reporting issues in a reproducible way can also be awarded
|
||||||
|
- Smaller but notable contributions like minor issues and documentation will be awarded with exclusive merch as we see fit.
|
||||||
|
|
||||||
|
## What you can do
|
||||||
|
|
||||||
|
- Head over to the [staging environment](https://documen.so/staging), check out the new version and give it a spin;
|
||||||
|
- Check out the [source code](https://github.com/documenso/documenso) on GitHub and look it over;
|
||||||
|
- Spin up the new version locally and try it out.
|
||||||
|
|
||||||
|
## How to get the bounties
|
||||||
|
|
||||||
|
- Report bugs by creating an issue here: [documen.so/issues](https://documen.so/issues);
|
||||||
|
- Fix bugs by creating a Pull Request (PR);
|
||||||
|
- Look over and add missing documentation/ Quickstarts and other useful resources.
|
||||||
|
|
||||||
|
We don't have a specific end date for Malfunction Mania. We plan to move the staging version into the production environment by the end of the month once we're happy with the results. Bug reports and fixes are, of course, always welcome going forward.
|
||||||
|
|
||||||
|
**[Follow Documenso on Twitter / X](https://documen.so/tw) and [join the Discord server](https://documen.so/discord) to get the latest about Malfunction Mania.**
|
||||||
@ -12,7 +12,7 @@ tags:
|
|||||||
|
|
||||||
Since we launched [Documenso 0.9 on Product Hunt](https://producthunt.com/products/documenso#documenso) last May, the team's been hard at work behind the scenes to ramp up development and design to deliver an excellent next version.
|
Since we launched [Documenso 0.9 on Product Hunt](https://producthunt.com/products/documenso#documenso) last May, the team's been hard at work behind the scenes to ramp up development and design to deliver an excellent next version.
|
||||||
|
|
||||||
Last week, Lucas shared the reasoning how [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
|
Last week, Lucas shared the reasoning on [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
|
||||||
|
|
||||||
Today, I'm pleased to share with you a preview of the next Documenso.
|
Today, I'm pleased to share with you a preview of the next Documenso.
|
||||||
|
|
||||||
@ -20,11 +20,11 @@ Today, I'm pleased to share with you a preview of the next Documenso.
|
|||||||
|
|
||||||
We redesigned the whole signing flow to make it more appealing and more convenient.
|
We redesigned the whole signing flow to make it more appealing and more convenient.
|
||||||
|
|
||||||
We improved the overall look and feel by making it more elegant and appropriately playful. Focused on the task at hand, but explicitly enjoying doing it.
|
We improved the overall look and feel by making it more elegant and appropriately playful. Focused on the task at hand, but explicitly enjoying doing it.
|
||||||
|
|
||||||
**We call it happy minimalism.**
|
**We call it happy minimalism.**
|
||||||
|
|
||||||
We paid particular attention to the moment of signing, which should be celebrated.
|
We paid particular attention to the moment of signing, which should be celebrated.
|
||||||
|
|
||||||
The image below is the final bloom of the completion celebration we added:
|
The image below is the final bloom of the completion celebration we added:
|
||||||
|
|
||||||
|
|||||||
62
apps/marketing/content/blog/shop.mdx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
title: Shop and Limited Edition "Mania" Shirt
|
||||||
|
description: Happy Launch Week Day 3. The limited edition "Malfunction Mania" shirt is here. Grab it, while you can.
|
||||||
|
authorName: 'Timur Ercan'
|
||||||
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2023-09-27
|
||||||
|
tags:
|
||||||
|
- Testing
|
||||||
|
- Rewrite
|
||||||
|
- Bounties
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/mania-shirt.png"
|
||||||
|
width="1260"
|
||||||
|
height="630"
|
||||||
|
alt="Malfunctioning Mania Themed Documenso Shirt"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
The Limited Edition "Malfunction Mania" Shirt - Only during Malfunction Mania
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
> TLDR; We have a fancy limited edition shirt. Contribute to Malfunction Mania to get one.
|
||||||
|
|
||||||
|
We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania) yesterday, and the first [Issues](https://github.com/documenso/documenso/issues) are coming in. As mentioned, there will be dollar bounties, but we also wanted to celebrate entering the final stage of version 1.0 with something special. This is why we created the limited edition shirt for Malfunction Mania. It will only be available during the runtime of Malfunction Mania. We have yet to set an exact end date, the next event in October, however, is looming, ready to end MM.
|
||||||
|
|
||||||
|
## Documenso Merch Shop
|
||||||
|
|
||||||
|
The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors.
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/shop.png"
|
||||||
|
width="1260"
|
||||||
|
height="630"
|
||||||
|
alt="Malfunctioning Mania Themed Documenso Shirt"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
Merch at Documenso is always given to those who deserve it.
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
## How to get the shirt
|
||||||
|
|
||||||
|
If you have been following us, you know we are not big on formalities but highly value rewarding merit. That being said, any worthwhile contribution has a chance to get one. To inspire, here are a few ideas on how to contribute to securing one:
|
||||||
|
|
||||||
|
- Report a bug with detailed reproduction details
|
||||||
|
- Fix a bug (you or somebody else reported)
|
||||||
|
- Analyze and describe a usability or user experience shortcoming
|
||||||
|
- Test the product in a systematic and least somewhat documented way
|
||||||
|
- Engage in discussion about the current version and its choices
|
||||||
|
- Raise awareness for Malfunction Mania and try out the [version currently in staging](https://documen.so/staging)
|
||||||
|
- Review the version with a video, stream, or screenshots and post about it
|
||||||
|
- Review existing or create missing documenso
|
||||||
|
- ...
|
||||||
|
|
||||||
|
**[Follow Documenso on Twitter / X](https://documen.so/tw) and [join the Discord server](https://documen.so/discord) to get the latest about Malfunction Mania.**
|
||||||
113
apps/marketing/content/blog/why-were-doing-a-rewrite.mdx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
title: Why we're doing a rewrite
|
||||||
|
description: As we move beyond MVP and onto creating the open signing infrastructure we all deserve we need to take a quick pit-stop.
|
||||||
|
authorName: 'Lucas Smith'
|
||||||
|
authorImage: '/blog/blog-author-lucas.png'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2023-08-05
|
||||||
|
tags:
|
||||||
|
- Community
|
||||||
|
- Development
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/blog-banner-rewrite.png"
|
||||||
|
width="1260"
|
||||||
|
height="630"
|
||||||
|
alt="Next generation documenso"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
The next generation of Documenso and signing infrastructure.
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
> TLDR; We're rewriting Documenso to move on from our MVP foundations and create an even better base for the project. This rewrite will provide us the opportunity to fix a few things within the project while enabling a faster development process moving forward.
|
||||||
|
|
||||||
|
# Introduction
|
||||||
|
|
||||||
|
At Documenso, we're building the next generation of signing infrastructure with a focus on making it inclusive and accessible for all. To do this we need to ensure that the software we write is also inclusive and accessible and for this reason we’ve decided to take a step back and perform a _quick_ rewrite.
|
||||||
|
|
||||||
|
Although we've achieved validated MVP status and gained paying customers, we're still quite far from our goal of creating a trusted, open signing experience. To move closer to that future, we need to step back and focus on the project's foundations to ensure we can solve all the items we set out to on our current homepage.
|
||||||
|
|
||||||
|
Fortunately, this wasn't a case of someone joining the team and proposing a rewrite due to a lack of understanding of the codebase and context surrounding it. Prior to joining Documenso as a co-founder, I had spent an extensive amount of time within the Documenso codebase and had a fairly intimate understanding of what was happening for the most part. This knowledge allowed me to make the fair and simultaneously hard call to take a quick pause so we can rebuild our current foundations to enable accessibility and a faster delivery time in the future.
|
||||||
|
|
||||||
|
# The Reasoning: TypeScript
|
||||||
|
|
||||||
|
Our primary reason for the rewrite is to better leverage the tools and technologies we've already chosen, namely TypeScript. While Documenso currently uses TypeScript, it's not fully taking advantage of its safety features, such as generics and type guards.
|
||||||
|
|
||||||
|
The codebase currently has several instances of `any` types, which is expected when working in an unknown domain where object models aren't fully understood before exploration and experimentation. These `any`s initially sped up development, but have since become a hindrance due to the lack of type information, combined with prop drilling. As a result, it's necessary to go through a lot of context to understand the root of any given issue.
|
||||||
|
|
||||||
|
The rewrite is using TypeScript to its full potential, ensuring that every interaction is strongly typed, both through general TypeScript tooling and the introduction of [Zod](https://github.com/colinhacks/zod), a validation library with excellent TypeScript support. With these choices, we can ensure that the codebase is robust to various inputs and states, as most issues will be caught during compile time and flagged within a developer's IDE.
|
||||||
|
|
||||||
|
# The Reasoning: Stronger API contracts
|
||||||
|
|
||||||
|
In line with our pattern of creating strongly typed contracts, we've decided to use [tRPC](https://github.com/trpc/trpc) for our internal API. This enables us to share types between our frontend and backend and establish a solid contract for interactions between the two. This is in contrast to the currently untyped API endpoints in Documenso, which are accessed using the `fetch` API that is itself untyped.
|
||||||
|
|
||||||
|
Using tRPC drastically reduces the chance of failures resulting from mundane things like argument or response shape changes during updates and upgrades. We made this decision easily because tRPC is a mature technology with no signs of losing momentum any time soon.
|
||||||
|
|
||||||
|
Additionally, many of our open-source friends have made the same choice for similar reasons.
|
||||||
|
|
||||||
|
# The Reasoning: Choosing exciting technologies
|
||||||
|
|
||||||
|
Although we already work with what I consider to be a fun stack that includes Next.js, Prisma, Tailwind, and more, it's no secret that contributors enjoy working with new technologies that benefit them in their own careers and projects.
|
||||||
|
|
||||||
|
To take advantage of this, we have decided to use Next.js 13 and React's new server component and actions architecture. Server components are currently popular among developers, with many loving and hating them at the same time.
|
||||||
|
|
||||||
|
I have personally worked with server components and actions since they were first released in October 2022 and have dealt with most of the hiccups and limitations along the way. Now, in July 2023, I believe they are in a much more stable place and are ready to be adopted, with their benefits being recognised by many.
|
||||||
|
|
||||||
|
By choosing to use server components and actions, we hope to encourage the community to participate more than they otherwise might. However, we are only choosing this because it has become more mature and stable. We will not choose things that are less likely to become the de-facto solution in the future, as we do not wish to inherit a pile of tech debt later on.
|
||||||
|
|
||||||
|
# The Reasoning: Allowing concurrent work
|
||||||
|
|
||||||
|
Another compelling reason for the rewrite was to effectively modularise code so we can work on features concurrently and without issue. This means extracting as much as possible out of components, API handlers and more and into a set of methods and functions that attempt to focus on just one thing.
|
||||||
|
|
||||||
|
In performing this work we should be able to easily make refactors and other changes to various parts of the code without stepping on each others feet, this also grants us the ability to upgrade or deprecate items as required by sticking to the contract of the previous method.
|
||||||
|
|
||||||
|
Additionally, this makes testing a much easier task as we can focus more on units of work rather than extensive end to end testing although we aim to have both, just not straight away.
|
||||||
|
|
||||||
|
# The Reasoning: Licensing of work
|
||||||
|
|
||||||
|
Another major reasoning for the rewrite is to ensure that all work performed on the project by both our internal team and external contributors is licensed in a way that benefits the project long-term. Prior to the rewrite contributors would create pull requests that would be merged in without any further process outside of the common code-review and testing cycles.
|
||||||
|
|
||||||
|
This was fine for the most part since we were simply working on the MVP but now as we move towards an infrastructure focus we intend on taking on enterprise clients who will have a need for a non-GPLv3 license since interpretations of it can be quite harmful to private hosting, to facilitate this we will require contributors to sign a contributor license agreement (CLA) prior to their changes being merged which will assign a perpetual license for us to use their code and relicense it as required such as for the use-case above.
|
||||||
|
|
||||||
|
While some might cringe at the idea of signing a CLA, we want to offer a compelling enterprise offering through means of dual-licensing. Great enterprise adoption is one of the cornerstones of our strategy and will be key to funding community and product development long-term.
|
||||||
|
|
||||||
|
_Do note that the above does not mean that we will ever go closed-source, it’s a point in our investor agreements that [https://github.com/documenso/documenso](https://github.com/documenso/documenso) will always remain available and open-source._
|
||||||
|
|
||||||
|
# Goals and Non-Goals
|
||||||
|
|
||||||
|
Rewriting an application is a monumental task that I have taken on and rejected many times in my career. As I get older, I become more hesitant to perform these rewrites because I understand that systems carry a lot of context and history. This makes them better suited for piecemeal refactoring instead, which avoids learning the lessons of the past all over again during the launch of the rewrite.
|
||||||
|
|
||||||
|
To ensure that we aren't just jumping off the deep end, I have set out a list of goals and non-goals to keep this rewrite lean and affordable.
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
- Provide a clean design and interface for the newly rewritten application that creates a sense of trust and security at first glance.
|
||||||
|
- Create a stable foundation and architecture that will allow for growth into our future roadmap items (teams, automation, workflows, etc.).
|
||||||
|
- Create a robust system that requires minimal context through strong contracts and typing.
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
|
||||||
|
- Change the database schema (we don't want to make migration harder than it needs to be, thus all changes must be additive).
|
||||||
|
- Add too many features that weren't in the system prior to the rewrite.
|
||||||
|
- Remove any features that were in the older version of Documenso, such as free signatures (signatures that have no corresponding field).
|
||||||
|
|
||||||
|
# Rollout Plan
|
||||||
|
|
||||||
|
Thanks to the constraints listed above our rollout will hopefully be fairly painless, still to be safe we plan on doing the following.
|
||||||
|
|
||||||
|
1. In the current [testing environment](https://test.documenso.com), create and sign a number of documents leaving many in varying states of completion.
|
||||||
|
2. Deploy the rewrite to the testing environment and verify that all existing documents and information is retrievable and modifiable without any issue.
|
||||||
|
3. Create another set of documents using the new rewrite and verify that all interactions between authoring and signing work as expected.
|
||||||
|
4. Repeat this until we reach a general confidence level (expectation of two weeks).
|
||||||
|
|
||||||
|
Once we’ve reached the desired confidence level with our testing environment we will look to deploy the rewrite to the production environment ensuring that we’ve performed all the required backups in the event of a catastrophic failure.
|
||||||
|
|
||||||
|
# Want to help out?
|
||||||
|
|
||||||
|
We’re currently working on the **[feat/refresh](https://github.com/documenso/documenso/tree/feat/refresh)** branch on GitHub, we aim to have a CLA available to sign in the coming days so we can start accepting external contributions asap. While we’re nearing the end-stage of the rewrite we will be throwing up a couple of bounties shortly for things like [Husky](https://github.com/typicode/husky) and [Changesets](https://github.com/changesets/changesets).
|
||||||
|
|
||||||
|
Keep an eye on our [GitHub issues](https://github.com/documenso/documenso/issues) to stay up to date!
|
||||||
BIN
apps/marketing/example/cert.p12
Normal file
@ -2,15 +2,60 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { withContentlayer } = require('next-contentlayer');
|
const { withContentlayer } = require('next-contentlayer');
|
||||||
|
|
||||||
const { parsed: env } = require('dotenv').config({
|
require('dotenv').config({
|
||||||
path: path.join(__dirname, '../../.env.local'),
|
path: path.join(__dirname, '../../.env.local'),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
experimental: {
|
||||||
|
serverActions: true,
|
||||||
|
serverActionsBodySizeLimit: '10mb',
|
||||||
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
||||||
env,
|
env: {
|
||||||
|
NEXT_PUBLIC_PROJECT: 'marketing',
|
||||||
|
},
|
||||||
|
modularizeImports: {
|
||||||
|
'lucide-react': {
|
||||||
|
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/:path*',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'x-dns-prefetch-control',
|
||||||
|
value: 'on',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'strict-transport-security',
|
||||||
|
value: 'max-age=31536000; includeSubDomains; preload',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'x-frame-options',
|
||||||
|
value: 'SAMEORIGIN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'x-content-type-options',
|
||||||
|
value: 'nosniff',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'referrer-policy',
|
||||||
|
value: 'strict-origin-when-cross-origin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'permissions-policy',
|
||||||
|
value:
|
||||||
|
'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withContentlayer(config);
|
module.exports = withContentlayer(config);
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
"clean": "rimraf .next && rimraf node_modules",
|
||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -18,14 +19,16 @@
|
|||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
"contentlayer": "^0.3.4",
|
"contentlayer": "^0.3.4",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.214.0",
|
"lucide-react": "^0.279.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "13.4.12",
|
"next": "13.4.19",
|
||||||
"next-auth": "4.22.3",
|
"next-auth": "4.22.3",
|
||||||
"next-contentlayer": "^0.3.4",
|
"next-contentlayer": "^0.3.4",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
|
"posthog-js": "^1.77.3",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
"react-confetti": "^6.1.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.8.0",
|
||||||
|
|||||||
3
apps/marketing/process-env.d.ts
vendored
@ -1,6 +1,7 @@
|
|||||||
declare namespace NodeJS {
|
declare namespace NodeJS {
|
||||||
export interface ProcessEnv {
|
export interface ProcessEnv {
|
||||||
NEXT_PUBLIC_SITE_URL?: string;
|
NEXT_PUBLIC_WEBAPP_URL?: string;
|
||||||
|
NEXT_PUBLIC_MARKETING_URL?: string;
|
||||||
|
|
||||||
NEXT_PRIVATE_DATABASE_URL: string;
|
NEXT_PRIVATE_DATABASE_URL: string;
|
||||||
|
|
||||||
|
|||||||
BIN
apps/marketing/public/blog/blog-author-duncan.jpeg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/marketing/public/blog/mania-shirt.png
Normal file
|
After Width: | Height: | Size: 839 KiB |
BIN
apps/marketing/public/blog/mm.png
Normal file
|
After Width: | Height: | Size: 862 KiB |
BIN
apps/marketing/public/blog/shop.png
Normal file
|
After Width: | Height: | Size: 956 KiB |
@ -39,7 +39,7 @@ export default function ContentPage({ params }: { params: { content: string } })
|
|||||||
const MDXContent = useMDXComponent(post.body.code);
|
const MDXContent = useMDXComponent(post.body.code);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="prose prose-slate mx-auto">
|
<article className="prose dark:prose-invert mx-auto">
|
||||||
<MDXContent components={mdxComponents} />
|
<MDXContent components={mdxComponents} />
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
title: `Documenso - ${blogPost.title}`,
|
title: `Documenso - ${blogPost.title}`,
|
||||||
|
description: blogPost.description,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -38,21 +39,21 @@ export default function BlogPostPage({ params }: { params: { post: string } }) {
|
|||||||
const MDXContent = useMDXComponent(post.body.code);
|
const MDXContent = useMDXComponent(post.body.code);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="prose prose-slate mx-auto py-8">
|
<article className="prose dark:prose-invert mx-auto py-8">
|
||||||
<div className="mb-6 text-center">
|
<div className="mb-6 text-center">
|
||||||
<time dateTime={post.date} className="mb-1 text-xs text-gray-600">
|
<time dateTime={post.date} className="text-muted-foreground mb-1 text-xs">
|
||||||
{new Date(post.date).toLocaleDateString()}
|
{new Date(post.date).toLocaleDateString()}
|
||||||
</time>
|
</time>
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold">{post.title}</h1>
|
<h1 className="text-3xl font-bold">{post.title}</h1>
|
||||||
|
|
||||||
<div className="not-prose relative -mt-2 flex items-center gap-x-4 border-b border-t py-4">
|
<div className="not-prose relative -mt-2 flex items-center gap-x-4 border-b border-t py-4">
|
||||||
<div className="h-10 w-10 rounded-full bg-gray-50">
|
<div className="bg-foreground h-10 w-10 rounded-full">
|
||||||
{post.authorImage && (
|
{post.authorImage && (
|
||||||
<img
|
<img
|
||||||
src={post.authorImage}
|
src={post.authorImage}
|
||||||
alt={`Image of ${post.authorName}`}
|
alt={`Image of ${post.authorName}`}
|
||||||
className="h-10 w-10 rounded-full bg-gray-50"
|
className="bg-foreground/10 h-10 w-10 rounded-full"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,13 +13,13 @@ export default function BlogPage() {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-3xl font-bold lg:text-5xl">From the blog</h1>
|
<h1 className="text-3xl font-bold lg:text-5xl">From the blog</h1>
|
||||||
|
|
||||||
<p className="mx-auto mt-4 max-w-xl text-center text-lg leading-normal text-[#31373D]">
|
<p className="text-muted-foreground mx-auto mt-4 max-w-xl text-center text-lg leading-normal">
|
||||||
Get the latest news from Documenso, including product updates, team announcements and
|
Get the latest news from Documenso, including product updates, team announcements and
|
||||||
more!
|
more!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 divide-y divide-slate-100 border-t border-slate-200 ">
|
<div className="divide-muted-foreground/20 border-muted-foreground/20 mt-10 divide-y border-t">
|
||||||
{blogPosts.map((post, i) => (
|
{blogPosts.map((post, i) => (
|
||||||
<article
|
<article
|
||||||
key={`blog-${i}`}
|
key={`blog-${i}`}
|
||||||
@ -57,12 +57,12 @@ export default function BlogPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mt-4 flex items-center gap-x-4">
|
<div className="relative mt-4 flex items-center gap-x-4">
|
||||||
<div className="h-10 w-10 rounded-full bg-slate-50">
|
<div className="bg-foreground/5 h-10 w-10 rounded-full">
|
||||||
{post.authorImage && (
|
{post.authorImage && (
|
||||||
<img
|
<img
|
||||||
src={post.authorImage}
|
src={post.authorImage}
|
||||||
alt={`Image of ${post.authorName}`}
|
alt={`Image of ${post.authorName}`}
|
||||||
className="h-10 w-10 rounded-full bg-slate-50"
|
className="bg-foreground/5 h-10 w-10 rounded-full"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -58,40 +58,40 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<h1 className="text-3xl font-bold text-slate-900 md:text-4xl">
|
<h1 className="text-foreground text-3xl font-bold md:text-4xl">
|
||||||
Welcome to the <span className="text-primary">open signing</span> revolution{' '}
|
Welcome to the <span className="text-primary">open signing</span> revolution{' '}
|
||||||
<u>{user.name}</u>
|
<u>{user.name}</u>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mt-4 max-w-prose text-base text-slate-500 md:text-lg">
|
<p className="text-muted-foreground mt-4 max-w-prose text-base md:text-lg">
|
||||||
It's not every day you get to be part of a revolution.
|
It's not every day you get to be part of a revolution.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-4 max-w-prose text-base text-slate-500 md:text-lg">
|
<p className="text-muted-foreground mt-4 max-w-prose text-base md:text-lg">
|
||||||
But today is that day, by signing up to Documenso, you're joining a movement of people who
|
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.
|
want to make the world a better place.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-4 max-w-prose text-base text-slate-500 md:text-lg">
|
<p className="text-muted-foreground mt-4 max-w-prose text-base md:text-lg">
|
||||||
We're going to change the way people sign documents. We're going to make it easier, faster,
|
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.
|
and more secure. And we're going to do it together.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<h2 className="text-2xl font-bold text-slate-900">Let's do it together</h2>
|
<h2 className="text-foreground text-2xl font-bold">Let's do it together</h2>
|
||||||
|
|
||||||
<div className="-mx-4 mt-8 flex md:-mx-8">
|
<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">
|
<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
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-4xl font-semibold text-slate-900 md:text-5xl',
|
'text-foreground text-4xl font-semibold md:text-5xl',
|
||||||
fontCaveat.className,
|
fontCaveat.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Timur
|
Timur
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-sm text-slate-500 md:text-lg">
|
<p className="text-muted-foreground text-sm md:text-lg">
|
||||||
Timur Ercan
|
Timur Ercan
|
||||||
<span className="block lg:hidden" />
|
<span className="block lg:hidden" />
|
||||||
<span className="hidden lg:inline"> - </span>
|
<span className="hidden lg:inline"> - </span>
|
||||||
@ -102,14 +102,14 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
<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">
|
<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
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-4xl font-semibold text-slate-900 md:text-5xl',
|
'text-foreground text-4xl font-semibold md:text-5xl',
|
||||||
fontCaveat.className,
|
fontCaveat.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Lucas
|
Lucas
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-sm text-slate-500 md:text-lg">
|
<p className="text-muted-foreground text-sm md:text-lg">
|
||||||
Lucas Smith
|
Lucas Smith
|
||||||
<span className="block lg:hidden" />
|
<span className="block lg:hidden" />
|
||||||
<span className="hidden lg:inline"> - </span>
|
<span className="hidden lg:inline"> - </span>
|
||||||
@ -119,12 +119,16 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
|
|
||||||
<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">
|
<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 && (
|
{signatureDataUrl && (
|
||||||
<img src={signatureDataUrl} alt="your-signature" className="max-w-[172px]" />
|
<img
|
||||||
|
src={signatureDataUrl}
|
||||||
|
alt="your-signature"
|
||||||
|
className="max-w-[172px] dark:invert"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{!signatureDataUrl && (
|
{!signatureDataUrl && (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-4xl font-semibold text-slate-900 md:text-5xl',
|
'text-foreground text-4xl font-semibold md:text-5xl',
|
||||||
fontCaveat.className,
|
fontCaveat.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -132,7 +136,7 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-sm text-slate-500 md:text-lg">
|
<p className="text-muted-foreground text-sm md:text-lg">
|
||||||
{user.name}
|
{user.name}
|
||||||
<span className="block lg:hidden" />
|
<span className="block lg:hidden" />
|
||||||
<span className="hidden lg:inline"> - </span>
|
<span className="hidden lg:inline"> - </span>
|
||||||
@ -143,25 +147,25 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<h2 className="text-2xl font-bold text-slate-900">Your sign in details</h2>
|
<h2 className="text-foreground text-2xl font-bold">Your sign in details</h2>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="text-lg text-slate-500">
|
<p className="text-muted-foreground text-lg">
|
||||||
<span className="font-bold">Email:</span> {user.email}
|
<span className="font-bold">Email:</span> {user.email}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-2 text-lg text-slate-500">
|
<p className="text-muted-foreground mt-2 text-lg">
|
||||||
<span className="font-bold">Password:</span>{' '}
|
<span className="font-bold">Password:</span>{' '}
|
||||||
<PasswordReveal password={password ?? 'password'} />
|
<PasswordReveal password={password ?? 'password'} />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 text-sm italic text-slate-500">
|
<p className="text-muted-foreground mt-4 text-sm italic">
|
||||||
This is a temporary password. Please change it as soon as possible.
|
This is a temporary password. Please change it as soon as possible.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`${process.env.NEXT_PUBLIC_APP_URL}/login`}
|
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="mt-4 block"
|
className="mt-4 block"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import React from 'react';
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
import { Footer } from '~/components/(marketing)/footer';
|
import { Footer } from '~/components/(marketing)/footer';
|
||||||
import { Header } from '~/components/(marketing)/header';
|
import { Header } from '~/components/(marketing)/header';
|
||||||
@ -8,15 +12,31 @@ export type MarketingLayoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||||
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
setScrollY(window.scrollY);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScroll);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative max-w-[100vw] overflow-y-auto overflow-x-hidden pt-20 md:pt-28">
|
<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">
|
<div
|
||||||
|
className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', {
|
||||||
|
'bg-background/50 backdrop-blur-md': scrollY > 5,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
|
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mx-auto max-w-screen-xl px-4 lg:px-8">{children}</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]" />
|
<Footer className="bg-background border-muted mt-24 border-t" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,7 +66,7 @@ export default async function OpenPage() {
|
|||||||
.then((res) => ZStargazersLiveResponse.parse(res));
|
.then((res) => ZStargazersLiveResponse.parse(res));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto mt-12 max-w-screen-lg">
|
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
|
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
|
||||||
|
|
||||||
|
|||||||
84
apps/marketing/src/app/(marketing)/oss-friends/container.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Variants, motion } from 'framer-motion';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
|
import { TOSSFriendsSchema } from './schema';
|
||||||
|
|
||||||
|
const ContainerVariants: Variants = {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.075,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CardVariants: Variants = {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
y: 50,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const randomDegrees = () => {
|
||||||
|
const degrees = [45, 120, -140, -45];
|
||||||
|
|
||||||
|
return degrees[Math.floor(Math.random() * degrees.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OSSFriendsContainerProps = {
|
||||||
|
className?: string;
|
||||||
|
ossFriends: TOSSFriendsSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OSSFriendsContainer = ({ className, ossFriends }: OSSFriendsContainerProps) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={cn('grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3', className)}
|
||||||
|
variants={ContainerVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
>
|
||||||
|
{ossFriends.map((friend, index) => (
|
||||||
|
<motion.div key={index} className="h-full w-full" variants={CardVariants}>
|
||||||
|
<Card
|
||||||
|
className="h-full"
|
||||||
|
degrees={randomDegrees()}
|
||||||
|
gradient={index % 2 === 0}
|
||||||
|
spotlight={index % 2 !== 0}
|
||||||
|
>
|
||||||
|
<CardContent className="flex h-full flex-col p-6">
|
||||||
|
<CardTitle>
|
||||||
|
<Link href={friend.href}>{friend.name}</Link>
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<p className="text-foreground mt-4 flex-1 text-sm">{friend.description}</p>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Link target="_blank" href={friend.href}>
|
||||||
|
<Button>Learn more</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,152 +1,23 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Variants, motion } from 'framer-motion';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
import backgroundPattern from '~/assets/background-pattern.png';
|
||||||
|
|
||||||
const OSSFriends = [
|
import { OSSFriendsContainer } from './container';
|
||||||
{
|
import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema';
|
||||||
name: 'BoxyHQ',
|
|
||||||
description:
|
|
||||||
'BoxyHQ’s suite of APIs for security and privacy helps engineering teams build and ship compliant cloud applications faster.',
|
|
||||||
href: 'https://boxyhq.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cal.com',
|
|
||||||
description:
|
|
||||||
'Cal.com is a scheduling tool that helps you schedule meetings without the back-and-forth emails.',
|
|
||||||
href: 'https://cal.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Crowd.dev',
|
|
||||||
description:
|
|
||||||
'Centralize community, product, and customer data to understand which companies are engaging with your open source project.',
|
|
||||||
href: 'https://www.crowd.dev',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Documenso',
|
|
||||||
description:
|
|
||||||
'The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.',
|
|
||||||
href: 'https://documenso.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Erxes',
|
|
||||||
description:
|
|
||||||
'The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences that work for all types of business.',
|
|
||||||
href: 'https://erxes.io',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Formbricks',
|
|
||||||
description:
|
|
||||||
'Survey granular user segments at any point in the user journey. Gather up to 6x more insights with targeted micro-surveys. All open-source.',
|
|
||||||
href: 'https://formbricks.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Forward Email',
|
|
||||||
description:
|
|
||||||
'Free email forwarding for custom domains. For 6 years and counting, we are the go-to email service for thousands of creators, developers, and businesses.',
|
|
||||||
href: 'https://forwardemail.net',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'GitWonk',
|
|
||||||
description:
|
|
||||||
'GitWonk is an open-source technical documentation tool, designed and built focusing on the developer experience.',
|
|
||||||
href: 'https://gitwonk.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Hanko',
|
|
||||||
description:
|
|
||||||
'Open-source authentication and user management for the passkey era. Integrated in minutes, for web and mobile apps.',
|
|
||||||
href: 'https://www.hanko.io',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'HTMX',
|
|
||||||
description:
|
|
||||||
'HTMX is a dependency-free JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML.',
|
|
||||||
href: 'https://htmx.org',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Infisical',
|
|
||||||
description:
|
|
||||||
'Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.',
|
|
||||||
href: 'https://infisical.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Novu',
|
|
||||||
description:
|
|
||||||
'The open-source notification infrastructure for developers. Simple components and APIs for managing all communication channels in one place.',
|
|
||||||
href: 'https://novu.co',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'OpenBB',
|
|
||||||
description:
|
|
||||||
'Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.',
|
|
||||||
href: 'https://openbb.co',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Sniffnet',
|
|
||||||
description:
|
|
||||||
'Sniffnet is a network monitoring tool to help you easily keep track of your Internet traffic.',
|
|
||||||
href: 'https://www.sniffnet.net',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Typebot',
|
|
||||||
description:
|
|
||||||
'Typebot gives you powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.',
|
|
||||||
href: 'https://typebot.io',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Webiny',
|
|
||||||
description:
|
|
||||||
'Open-source enterprise-grade serverless CMS. Own your data. Scale effortlessly. Customize everything.',
|
|
||||||
href: 'https://www.webiny.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Webstudio',
|
|
||||||
description: 'Webstudio is an open source alternative to Webflow',
|
|
||||||
href: 'https://webstudio.is',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ContainerVariants: Variants = {
|
export default async function OSSFriendsPage() {
|
||||||
initial: {
|
const ossFriends: TOSSFriendsSchema = await fetch('https://formbricks.com/api/oss-friends', {
|
||||||
opacity: 0,
|
next: {
|
||||||
},
|
revalidate: 3600,
|
||||||
animate: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.075,
|
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
};
|
.then(async (res) => res.json())
|
||||||
|
.then(async (data) => z.object({ data: ZOSSFriendsSchema }).parseAsync(data))
|
||||||
|
.then(({ data }) => data)
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
const CardVariants: Variants = {
|
|
||||||
initial: {
|
|
||||||
opacity: 0,
|
|
||||||
y: 50,
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const randomDegrees = () => {
|
|
||||||
const degrees = [45, 120, -140, -45];
|
|
||||||
|
|
||||||
return degrees[Math.floor(Math.random() * degrees.length)];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OSSFriendsPage() {
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mt-12">
|
<div className="relative mt-12">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@ -154,49 +25,19 @@ export default function OSSFriendsPage() {
|
|||||||
Our <span title="Open Source Software">OSS</span> Friends
|
Our <span title="Open Source Software">OSS</span> Friends
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mx-auto mt-4 max-w-[55ch] text-lg leading-normal text-[#31373D]">
|
<p className="text-foreground mx-auto mt-4 max-w-[55ch] text-lg leading-normal">
|
||||||
We love open source and so should you, below you can find a list of our friends who are
|
We love open source and so should you, below you can find a list of our friends who are
|
||||||
just as passionate about open source as we are.
|
just as passionate about open source as we are.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<OSSFriendsContainer className="mt-12" ossFriends={ossFriends} />
|
||||||
className="mt-12 grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3"
|
|
||||||
variants={ContainerVariants}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
>
|
|
||||||
{OSSFriends.map((friend, index) => (
|
|
||||||
<motion.div key={index} className="h-full w-full" variants={CardVariants}>
|
|
||||||
<Card
|
|
||||||
className="h-full"
|
|
||||||
degrees={randomDegrees()}
|
|
||||||
gradient={index % 2 === 0}
|
|
||||||
spotlight={index % 2 !== 0}
|
|
||||||
>
|
|
||||||
<CardContent className="flex h-full flex-col p-6">
|
|
||||||
<CardTitle>
|
|
||||||
<Link href={friend.href}>{friend.name}</Link>
|
|
||||||
</CardTitle>
|
|
||||||
|
|
||||||
<p className="mt-4 flex-1 text-sm text-slate-700">{friend.description}</p>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
<Link target="_blank" href={friend.href}>
|
|
||||||
<Button>Learn more</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 -z-10 flex items-start justify-center">
|
<div className="absolute inset-0 -z-10 flex items-start justify-center">
|
||||||
<Image
|
<Image
|
||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="-mr-[15vw] -mt-[15vh] h-full max-h-[150vh] scale-125 object-cover md:-mr-[50vw] md:scale-150 lg:scale-[175%]"
|
className="-mr-[15vw] -mt-[15vh] h-full max-h-[150vh] scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:-mr-[50vw] md:scale-150 lg:scale-[175%]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
11
apps/marketing/src/app/(marketing)/oss-friends/schema.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZOSSFriendsSchema = z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
href: z.string().url(),
|
||||||
|
description: z.string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TOSSFriendsSchema = z.infer<typeof ZOSSFriendsSchema>;
|
||||||
@ -20,14 +20,14 @@ export type PricingPageProps = {
|
|||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="mt-12">
|
<div className="mt-6 sm:mt-12">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-3xl font-bold lg:text-5xl">Pricing</h1>
|
<h1 className="text-3xl font-bold lg:text-5xl">Pricing</h1>
|
||||||
|
|
||||||
<p className="mt-4 text-lg leading-normal text-[#31373D]">
|
<p className="text-foreground mt-4 text-lg leading-normal">
|
||||||
Designed for every stage of your journey.
|
Designed for every stage of your journey.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg leading-normal text-[#31373D]">Get started today.</p>
|
<p className="text-foreground text-lg leading-normal">Get started today.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
@ -45,7 +45,7 @@ export default function PricingPage() {
|
|||||||
What is the difference between the plans?
|
What is the difference between the plans?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
|
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||||
You can self-host Documenso for free or use our ready-to-use hosted version. The
|
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
|
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
|
adopters of the community plan will get access to all features we build this year, for
|
||||||
@ -59,7 +59,7 @@ export default function PricingPage() {
|
|||||||
How do you handle my data?
|
How do you handle my data?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
|
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||||
Securely. Our data centers are located in Frankfurt (Germany), giving us the best
|
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
|
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.
|
best practices to ensure the security and integrity of the data entrusted to us.
|
||||||
@ -71,7 +71,7 @@ export default function PricingPage() {
|
|||||||
Why should I use your hosting service?
|
Why should I use your hosting service?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
|
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||||
Using our hosted version is the easiest way to get started, you can simply subscribe
|
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
|
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
|
on your business. Additionally, when using our hosted version you benefit from our
|
||||||
@ -84,7 +84,7 @@ export default function PricingPage() {
|
|||||||
How can I contribute?
|
How can I contribute?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
|
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||||
That's awesome. You can take a look at the current{' '}
|
That's awesome. You can take a look at the current{' '}
|
||||||
<Link
|
<Link
|
||||||
className="text-documenso-700 font-bold"
|
className="text-documenso-700 font-bold"
|
||||||
@ -111,7 +111,7 @@ export default function PricingPage() {
|
|||||||
Can I use Documenso commercially?
|
Can I use Documenso commercially?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
|
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||||
Yes! Documenso is offered under the GNU AGPL V3 open source license. This means you
|
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
|
can use it for free and even modify it to fit your needs, as long as you publish your
|
||||||
changes under the same license.
|
changes under the same license.
|
||||||
@ -123,7 +123,7 @@ export default function PricingPage() {
|
|||||||
Why should I prefer Documenso over DocuSign or some other signing tool?
|
Why should I prefer Documenso over DocuSign or some other signing tool?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
|
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||||
Documenso is a community effort to create an open and vibrant ecosystem around a tool,
|
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
|
everybody is free to use and adapt. By being truly open we want to create trusted
|
||||||
infrastructure for the future of the internet.
|
infrastructure for the future of the internet.
|
||||||
@ -135,7 +135,7 @@ export default function PricingPage() {
|
|||||||
Where can I get support?
|
Where can I get support?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
|
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||||
We are happy to assist you at{' '}
|
We are happy to assist you at{' '}
|
||||||
<Link
|
<Link
|
||||||
className="text-documenso-700 font-bold"
|
className="text-documenso-700 font-bold"
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getDocumentAndRecipientByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { SinglePlayerModeSuccess } from '~/components/(marketing)/single-player-mode/single-player-mode-success';
|
||||||
|
|
||||||
|
export type SinglePlayerModeSuccessPageProps = {
|
||||||
|
params: {
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SinglePlayerModeSuccessPage({
|
||||||
|
params: { token },
|
||||||
|
}: SinglePlayerModeSuccessPageProps) {
|
||||||
|
if (!token) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await getDocumentAndRecipientByToken({
|
||||||
|
token,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!document || document.status !== DocumentStatus.COMPLETED) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SinglePlayerModeSuccess document={document} />;
|
||||||
|
}
|
||||||
244
apps/marketing/src/app/(marketing)/single-player-mode/page.tsx
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { Field, Prisma, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
|
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
|
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
||||||
|
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
||||||
|
import {
|
||||||
|
DocumentFlowFormContainer,
|
||||||
|
DocumentFlowFormContainerHeader,
|
||||||
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
|
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
|
||||||
|
|
||||||
|
type SinglePlayerModeStep = 'fields' | 'sign';
|
||||||
|
|
||||||
|
export default function SinglePlayerModePage() {
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
|
||||||
|
const [fields, setFields] = useState<Field[]>([]);
|
||||||
|
|
||||||
|
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
||||||
|
fields: {
|
||||||
|
title: 'Add document',
|
||||||
|
description: 'Upload a document and add fields.',
|
||||||
|
stepIndex: 1,
|
||||||
|
onBackStep: uploadedFile
|
||||||
|
? () => {
|
||||||
|
setUploadedFile(null);
|
||||||
|
setFields([]);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onNextStep: () => setStep('sign'),
|
||||||
|
},
|
||||||
|
sign: {
|
||||||
|
title: 'Sign',
|
||||||
|
description: 'Enter your details.',
|
||||||
|
stepIndex: 2,
|
||||||
|
onBackStep: () => setStep('fields'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
analytics.startSessionRecording('marketing_session_recording_spm');
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
analytics.stopSessionRecording();
|
||||||
|
};
|
||||||
|
}, [analytics]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert the selected fields into the local state.
|
||||||
|
*/
|
||||||
|
const onFieldsSubmit = (data: TAddFieldsFormSchema) => {
|
||||||
|
if (!uploadedFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFields(
|
||||||
|
data.fields.map((field, i) => ({
|
||||||
|
id: i,
|
||||||
|
documentId: -1,
|
||||||
|
recipientId: -1,
|
||||||
|
type: field.type,
|
||||||
|
page: field.pageNumber,
|
||||||
|
positionX: new Prisma.Decimal(field.pageX),
|
||||||
|
positionY: new Prisma.Decimal(field.pageY),
|
||||||
|
width: new Prisma.Decimal(field.pageWidth),
|
||||||
|
height: new Prisma.Decimal(field.pageHeight),
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
analytics.capture('Marketing: SPM - Fields added');
|
||||||
|
|
||||||
|
documentFlow.fields.onNextStep?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload, create, sign and send the document.
|
||||||
|
*/
|
||||||
|
const onSignSubmit = async (data: TAddSignatureFormSchema) => {
|
||||||
|
if (!uploadedFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const putFileData = await putFile(uploadedFile.file);
|
||||||
|
|
||||||
|
const documentToken = await createSinglePlayerDocument({
|
||||||
|
documentData: {
|
||||||
|
type: putFileData.type,
|
||||||
|
data: putFileData.data,
|
||||||
|
},
|
||||||
|
documentName: uploadedFile.file.name,
|
||||||
|
signer: data,
|
||||||
|
fields: fields.map((field) => ({
|
||||||
|
page: field.page,
|
||||||
|
type: field.type,
|
||||||
|
positionX: field.positionX.toNumber(),
|
||||||
|
positionY: field.positionY.toNumber(),
|
||||||
|
width: field.width.toNumber(),
|
||||||
|
height: field.height.toNumber(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
analytics.capture('Marketing: SPM - Document signed', {
|
||||||
|
signer: data.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`/single-player-mode/${documentToken}/success`);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholderRecipient: Recipient = {
|
||||||
|
id: -1,
|
||||||
|
documentId: -1,
|
||||||
|
email: '',
|
||||||
|
name: '',
|
||||||
|
token: '',
|
||||||
|
expired: null,
|
||||||
|
signedAt: null,
|
||||||
|
readStatus: 'OPENED',
|
||||||
|
signingStatus: 'NOT_SIGNED',
|
||||||
|
sendStatus: 'NOT_SENT',
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileDrop = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const base64String = base64.encode(new Uint8Array(arrayBuffer));
|
||||||
|
|
||||||
|
setUploadedFile({
|
||||||
|
file,
|
||||||
|
fileBase64: `data:application/pdf;base64,${base64String}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
analytics.capture('Marketing: SPM - Document uploaded');
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 sm:mt-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
|
||||||
|
|
||||||
|
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
|
||||||
|
View our{' '}
|
||||||
|
<Link
|
||||||
|
href={'/pricing'}
|
||||||
|
target="_blank"
|
||||||
|
className="hover:text-foreground/80 font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
community plan
|
||||||
|
</Link>{' '}
|
||||||
|
for exclusive features, including the ability to collaborate with multiple signers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 grid w-full grid-cols-12 gap-8">
|
||||||
|
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||||
|
{uploadedFile ? (
|
||||||
|
<Card gradient>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<LazyPDFViewer document={uploadedFile.fileBase64} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
|
<DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
|
||||||
|
<DocumentFlowFormContainerHeader
|
||||||
|
title={currentDocumentFlow.title}
|
||||||
|
description={currentDocumentFlow.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Add fields to PDF page. */}
|
||||||
|
{step === 'fields' && (
|
||||||
|
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
|
||||||
|
<AddFieldsFormPartial
|
||||||
|
documentFlow={documentFlow.fields}
|
||||||
|
hideRecipients={true}
|
||||||
|
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
|
fields={fields}
|
||||||
|
onSubmit={onFieldsSubmit}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enter user details and signature. */}
|
||||||
|
{step === 'sign' && (
|
||||||
|
<AddSignatureFormPartial
|
||||||
|
documentFlow={documentFlow.sign}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
|
fields={fields}
|
||||||
|
onSubmit={onSignSubmit}
|
||||||
|
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
||||||
|
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DocumentFlowFormContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,20 @@
|
|||||||
import { Inter } from 'next/font/google';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
import { Caveat, Inter } from 'next/font/google';
|
||||||
|
|
||||||
|
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
|
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||||
|
|
||||||
|
import { ThemeProvider } from '~/providers/next-theme';
|
||||||
import { PlausibleProvider } from '~/providers/plausible';
|
import { PlausibleProvider } from '~/providers/plausible';
|
||||||
|
import { PostHogPageview } from '~/providers/posthog';
|
||||||
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||||
|
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||||
@ -21,20 +29,26 @@ export const metadata = {
|
|||||||
description:
|
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.',
|
'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',
|
type: 'website',
|
||||||
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`],
|
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
site: '@documenso',
|
site: '@documenso',
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`],
|
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
|
||||||
description:
|
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.',
|
'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 }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const flags = await getAllAnonymousFlags();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={fontInter.variable} suppressHydrationWarning>
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={cn(fontInter.variable, fontCaveat.variable)}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
<head>
|
<head>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
@ -42,8 +56,17 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
<Suspense>
|
||||||
|
<PostHogPageview />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<PlausibleProvider>{children}</PlausibleProvider>
|
<FeatureFlagProvider initialFlags={flags}>
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
|
<PlausibleProvider>{children}</PlausibleProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</FeatureFlagProvider>
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
65
apps/marketing/src/app/not-found.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import backgroundPattern from '~/assets/background-pattern.png';
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative max-w-[100vw] overflow-hidden')}>
|
||||||
|
<div className="absolute -inset-24 -z-10">
|
||||||
|
<motion.div
|
||||||
|
className="flex h-full w-full items-center justify-center"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 0.8, transition: { duration: 0.5, delay: 0.5 } }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={backgroundPattern}
|
||||||
|
alt="background pattern"
|
||||||
|
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-100 lg:scale-[100%]"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container mx-auto flex h-full min-h-screen items-center px-6 py-32">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground font-semibold">404 Page not found</p>
|
||||||
|
|
||||||
|
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4 text-sm">
|
||||||
|
The page you are looking for was moved, removed, renamed or might never have existed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-32"
|
||||||
|
onClick={() => {
|
||||||
|
void router.back();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button className="w-32" asChild>
|
||||||
|
<Link href="/">Home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,11 +4,11 @@ import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
|||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: [
|
||||||
userAgent: '*',
|
{
|
||||||
allow: '/*',
|
userAgent: '*',
|
||||||
disallow: ['/_next/*'],
|
},
|
||||||
},
|
],
|
||||||
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
apps/marketing/src/assets/signing-celebration.png
Normal file
|
After Width: | Height: | Size: 20 MiB |
@ -41,7 +41,7 @@ export const Callout = ({ starCount }: CalloutProps) => {
|
|||||||
onClick={onSignUpClick}
|
onClick={onSignUpClick}
|
||||||
>
|
>
|
||||||
Get the Community Plan
|
Get the Community Plan
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
||||||
$30/mo. forever!
|
$30/mo. forever!
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
@ -55,7 +55,7 @@ export const Callout = ({ starCount }: CalloutProps) => {
|
|||||||
<Github className="mr-2 h-5 w-5" />
|
<Github className="mr-2 h-5 w-5" />
|
||||||
Star on Github
|
Star on Github
|
||||||
{starCount && starCount > 0 && (
|
{starCount && starCount > 0 && (
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
||||||
{starCount.toLocaleString('en-US')}
|
{starCount.toLocaleString('en-US')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { usePlausible } from 'next-plausible';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -43,9 +44,11 @@ export type ClaimPlanDialogProps = {
|
|||||||
|
|
||||||
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
|
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const { toast } = useToast();
|
const analytics = useAnalytics();
|
||||||
const event = usePlausible();
|
const event = usePlausible();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
|
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -73,10 +76,12 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
event('claim-plan-pricing');
|
event('claim-plan-pricing');
|
||||||
|
analytics.capture('Marketing: Claim plan', { planId, email });
|
||||||
|
|
||||||
window.location.href = redirectUrl;
|
window.location.href = redirectUrl;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
event('claim-plan-failed');
|
event('claim-plan-failed');
|
||||||
|
analytics.capture('Marketing: Claim plan failure', { planId, email });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: 'Something went wrong',
|
||||||
@ -118,7 +123,7 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-slate-500">Name</Label>
|
<Label className="text-muted-foreground">Name</Label>
|
||||||
|
|
||||||
<Input type="text" className="mt-2" {...register('name')} autoFocus />
|
<Input type="text" className="mt-2" {...register('name')} autoFocus />
|
||||||
|
|
||||||
@ -126,7 +131,7 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-slate-500">Email</Label>
|
<Label className="text-muted-foreground">Email</Label>
|
||||||
|
|
||||||
<Input type="email" className="mt-2" {...register('email')} />
|
<Input type="email" className="mt-2" {...register('email')} />
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import Confetti from 'react-confetti';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size';
|
||||||
|
|
||||||
|
export default function ConfettiScreen({
|
||||||
|
numberOfPieces: numberOfPiecesProp = 200,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof Confetti> & { duration?: number }) {
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
const { width, height } = useWindowSize();
|
||||||
|
|
||||||
|
const [numberOfPieces, setNumberOfPieces] = useState(numberOfPiecesProp);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!props.duration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setNumberOfPieces(0);
|
||||||
|
}, props.duration);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [props.duration]);
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<Confetti
|
||||||
|
{...props}
|
||||||
|
className="w-full"
|
||||||
|
numberOfPieces={numberOfPieces}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -22,7 +22,7 @@ export const FasterSmarterBeautifulBento = ({
|
|||||||
<Image
|
<Image
|
||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
||||||
@ -33,41 +33,53 @@ export const FasterSmarterBeautifulBento = ({
|
|||||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
||||||
<Card className="col-span-2" degrees={45} gradient>
|
<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]">
|
<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">
|
<p className="text-foreground/80 col-span-12 leading-relaxed lg:col-span-6">
|
||||||
<strong className="block">Fast.</strong>
|
<strong className="block">Fast.</strong>
|
||||||
When it comes to sending or receiving a contract, you can count on lightning-fast
|
When it comes to sending or receiving a contract, you can count on lightning-fast
|
||||||
speeds.
|
speeds.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
|
<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" />
|
<Image
|
||||||
|
src={cardFastFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="max-w-[80%] dark:contrast-[70%] dark:hue-rotate-180 dark:invert lg:max-w-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Beautiful.</strong>
|
<strong className="block">Beautiful.</strong>
|
||||||
Because signing should be celebrated. That’s why we care about the smallest detail in
|
Because signing should be celebrated. That’s why we care about the smallest detail in
|
||||||
our product.
|
our product.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardBeautifulFigure} alt="its fast" className="w-full max-w-xs" />
|
<Image
|
||||||
|
src={cardBeautifulFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-xs dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Smart.</strong>
|
<strong className="block">Smart.</strong>
|
||||||
Our custom templates come with smart rules that can help you save time and energy.
|
Our custom templates come with smart rules that can help you save time and energy.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardSmartFigure} alt="its fast" className="w-full max-w-[16rem]" />
|
<Image
|
||||||
|
src={cardSmartFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-[16rem] dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Github, MessagesSquare, Twitter } from 'lucide-react';
|
import { Github, MessagesSquare, Moon, Sun, Twitter } from 'lucide-react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
@ -17,6 +20,7 @@ const SOCIAL_LINKS = [
|
|||||||
|
|
||||||
const FOOTER_LINKS = [
|
const FOOTER_LINKS = [
|
||||||
{ href: '/pricing', text: 'Pricing' },
|
{ href: '/pricing', text: 'Pricing' },
|
||||||
|
{ href: '/single-player-mode', text: 'Single Player Mode' },
|
||||||
{ href: '/blog', text: 'Blog' },
|
{ href: '/blog', text: 'Blog' },
|
||||||
{ href: '/open', text: 'Open' },
|
{ href: '/open', text: 'Open' },
|
||||||
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
||||||
@ -26,17 +30,30 @@ const FOOTER_LINKS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
export const Footer = ({ className, ...props }: FooterProps) => {
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('border-t py-12', className)} {...props}>
|
<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 className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
||||||
<div>
|
<div>
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={0}></Image>
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="dark:invert"
|
||||||
|
width={170}
|
||||||
|
height={0}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-4 text-[#8D8D8D]">
|
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-4">
|
||||||
{SOCIAL_LINKS.map((link, index) => (
|
{SOCIAL_LINKS.map((link, index) => (
|
||||||
<Link key={index} href={link.href} target="_blank" className="hover:text-[#6D6D6D]">
|
<Link
|
||||||
|
key={index}
|
||||||
|
href={link.href}
|
||||||
|
target="_blank"
|
||||||
|
className="text-muted-foreground hover:text-muted-foreground/80"
|
||||||
|
>
|
||||||
{link.icon}
|
{link.icon}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@ -49,17 +66,29 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
key={index}
|
key={index}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
target={link.target}
|
target={link.target}
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 text-sm"
|
||||||
>
|
>
|
||||||
{link.text}
|
{link.text}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto mt-4 w-full max-w-screen-xl px-8 md:mt-12 lg:mt-24">
|
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap justify-between gap-4 px-8 md:mt-12 lg:mt-24">
|
||||||
<p className="text-sm text-[#8D8D8D]">
|
<p className="text-muted-foreground text-sm">
|
||||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
||||||
|
<button type="button" className="text-muted-foreground" onClick={() => setTheme('light')}>
|
||||||
|
<Sun className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Light</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="text-muted-foreground" onClick={() => setTheme('dark')}>
|
||||||
|
<Moon className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Dark</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { HTMLAttributes, useState } from 'react';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
import { HamburgerMenu } from './mobile-hamburger';
|
import { HamburgerMenu } from './mobile-hamburger';
|
||||||
@ -15,29 +16,59 @@ export type HeaderProps = HTMLAttributes<HTMLElement>;
|
|||||||
export const Header = ({ className, ...props }: HeaderProps) => {
|
export const Header = ({ className, ...props }: HeaderProps) => {
|
||||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const isSinglePlayerModeMarketingEnabled = getFlag('marketing_header_single_player_mode');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={cn('flex items-center justify-between', className)} {...props}>
|
<header className={cn('flex items-center justify-between', className)} {...props}>
|
||||||
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
<div className="flex items-center space-x-4">
|
||||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
||||||
</Link>
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="dark:invert"
|
||||||
|
width={170}
|
||||||
|
height={25}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{isSinglePlayerModeMarketingEnabled && (
|
||||||
|
<Link
|
||||||
|
href="/single-player-mode"
|
||||||
|
className="bg-primary dark:text-background rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
|
||||||
|
>
|
||||||
|
Try now!
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="hidden items-center gap-x-6 md:flex">
|
<div className="hidden items-center gap-x-6 md:flex">
|
||||||
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
<Link
|
||||||
|
href="/pricing"
|
||||||
|
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||||
|
>
|
||||||
Pricing
|
Pricing
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/blog" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
<Link
|
||||||
|
href="/blog"
|
||||||
|
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||||
|
>
|
||||||
Blog
|
Blog
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/open" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
<Link
|
||||||
|
href="/open"
|
||||||
|
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||||
|
>
|
||||||
Open
|
Open
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="https://app.documenso.com/login"
|
href="https://app.documenso.com/login"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -6,7 +6,9 @@ import Link from 'next/link';
|
|||||||
import { Variants, motion } from 'framer-motion';
|
import { Variants, motion } from 'framer-motion';
|
||||||
import { Github } from 'lucide-react';
|
import { Github } from 'lucide-react';
|
||||||
import { usePlausible } from 'next-plausible';
|
import { usePlausible } from 'next-plausible';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
@ -51,6 +53,10 @@ const HeroTitleVariants: Variants = {
|
|||||||
export const Hero = ({ className, ...props }: HeroProps) => {
|
export const Hero = ({ className, ...props }: HeroProps) => {
|
||||||
const event = usePlausible();
|
const event = usePlausible();
|
||||||
|
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const heroMarketingCTA = getFlag('marketing_landing_hero_cta');
|
||||||
|
|
||||||
const onSignUpClick = () => {
|
const onSignUpClick = () => {
|
||||||
const el = document.getElementById('email');
|
const el = document.getElementById('email');
|
||||||
|
|
||||||
@ -80,7 +86,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
<Image
|
<Image
|
||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="-mr-[50vw] -mt-[15vh] h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
className="-mr-[50vw] -mt-[15vh] h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@ -109,7 +115,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
onClick={onSignUpClick}
|
onClick={onSignUpClick}
|
||||||
>
|
>
|
||||||
Get the Community Plan
|
Get the Community Plan
|
||||||
<span className="bg-primary -mr-2 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
||||||
$30/mo. forever!
|
$30/mo. forever!
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
@ -122,23 +128,45 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
{match(heroMarketingCTA)
|
||||||
variants={HeroTitleVariants}
|
.with('spm', () => (
|
||||||
initial="initial"
|
<motion.div
|
||||||
animate="animate"
|
variants={HeroTitleVariants}
|
||||||
className="mt-8 flex flex-col items-center justify-center gap-x-6 gap-y-4"
|
initial="initial"
|
||||||
>
|
animate="animate"
|
||||||
<Link
|
className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition duration-300"
|
||||||
href="https://www.producthunt.com/posts/documenso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-documenso"
|
>
|
||||||
target="_blank"
|
<Link href="/single-player-mode" className="block px-4 py-2 text-center">
|
||||||
>
|
<h2 className="text-muted-foreground text-xs font-semibold">
|
||||||
<img
|
Introducing Single Player Mode
|
||||||
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=395047&theme=light&period=daily"
|
</h2>
|
||||||
alt="Documenso - The open source DocuSign alternative | Product Hunt"
|
|
||||||
style={{ width: '250px', height: '54px' }}
|
<h1 className="text-foreground mt-1.5 font-medium leading-5">
|
||||||
/>
|
Self sign for free!
|
||||||
</Link>
|
</h1>
|
||||||
</motion.div>
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
))
|
||||||
|
.with('productHunt', () => (
|
||||||
|
<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>
|
||||||
|
))
|
||||||
|
.otherwise(() => null)}
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mt-12"
|
className="mt-12"
|
||||||
|
|||||||
@ -14,6 +14,10 @@ export type MobileNavigationProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MENU_NAVIGATION_LINKS = [
|
export const MENU_NAVIGATION_LINKS = [
|
||||||
|
{
|
||||||
|
href: '/single-player-mode',
|
||||||
|
text: 'Single Player Mode',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/blog',
|
href: '/blog',
|
||||||
text: 'Blog',
|
text: 'Blog',
|
||||||
@ -22,6 +26,10 @@ export const MENU_NAVIGATION_LINKS = [
|
|||||||
href: '/pricing',
|
href: '/pricing',
|
||||||
text: 'Pricing',
|
text: 'Pricing',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/open',
|
||||||
|
text: 'Open',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: 'https://status.documenso.com',
|
href: 'https://status.documenso.com',
|
||||||
text: 'Status',
|
text: 'Status',
|
||||||
@ -51,7 +59,13 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||||
<SheetContent className="w-full max-w-[400px]">
|
<SheetContent className="w-full max-w-[400px]">
|
||||||
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
|
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
|
||||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="dark:invert"
|
||||||
|
width={170}
|
||||||
|
height={25}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -59,7 +73,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
transition={{
|
transition={{
|
||||||
staggerChildren: 0.2,
|
staggerChildren: 0.03,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
|
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
|
||||||
@ -75,12 +89,13 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
x: 0,
|
x: 0,
|
||||||
transition: {
|
transition: {
|
||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
|
ease: 'backInOut',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
className="text-2xl font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
||||||
href={href}
|
href={href}
|
||||||
onClick={() => handleMenuItemClick()}
|
onClick={() => handleMenuItemClick()}
|
||||||
>
|
>
|
||||||
@ -94,7 +109,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
<Link
|
<Link
|
||||||
href="https://twitter.com/documenso"
|
href="https://twitter.com/documenso"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="text-foreground hover:text-foreground/80"
|
||||||
>
|
>
|
||||||
<Twitter className="h-6 w-6" />
|
<Twitter className="h-6 w-6" />
|
||||||
</Link>
|
</Link>
|
||||||
@ -102,7 +117,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
<Link
|
<Link
|
||||||
href="https://github.com/documenso/documenso"
|
href="https://github.com/documenso/documenso"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="text-foreground hover:text-foreground/80"
|
||||||
>
|
>
|
||||||
<Github className="h-6 w-6" />
|
<Github className="h-6 w-6" />
|
||||||
</Link>
|
</Link>
|
||||||
@ -110,7 +125,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
<Link
|
<Link
|
||||||
href="https://documen.so/discord"
|
href="https://documen.so/discord"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="text-foreground hover:text-foreground/80"
|
||||||
>
|
>
|
||||||
<MessagesSquare className="h-6 w-6" />
|
<MessagesSquare className="h-6 w-6" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplat
|
|||||||
<Image
|
<Image
|
||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
||||||
@ -30,41 +30,53 @@ export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplat
|
|||||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
||||||
<Card className="col-span-2" degrees={45} gradient>
|
<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]">
|
<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">
|
<p className="text-foreground/80 col-span-12 leading-relaxed lg:col-span-6">
|
||||||
<strong className="block">Open Source or Hosted.</strong>
|
<strong className="block">Open Source or Hosted.</strong>
|
||||||
It’s up to you. Either clone our repository or rely on our easy to use hosting
|
It’s up to you. Either clone our repository or rely on our easy to use hosting
|
||||||
solution.
|
solution.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
|
<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" />
|
<Image
|
||||||
|
src={cardOpenFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="max-w-[80%] dark:contrast-[70%] dark:hue-rotate-180 dark:invert lg:max-w-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Build on top.</strong>
|
<strong className="block">Build on top.</strong>
|
||||||
Make it your own through advanced customization and adjustability.
|
Make it your own through advanced customization and adjustability.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardBuildFigure} alt="its fast" className="w-full max-w-xs" />
|
<Image
|
||||||
|
src={cardBuildFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-xs dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Template Store (Soon).</strong>
|
<strong className="block">Template Store (Soon).</strong>
|
||||||
Choose a template from the community app store. Or submit your own template for others
|
Choose a template from the community app store. Or submit your own template for others
|
||||||
to use.
|
to use.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardTemplateFigure} alt="its fast" className="w-full max-w-sm" />
|
<Image
|
||||||
|
src={cardTemplateFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-sm dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -41,10 +41,13 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.button
|
<motion.button
|
||||||
key="MONTHLY"
|
key="MONTHLY"
|
||||||
className={cn('relative flex items-center gap-x-2.5 px-1 py-2.5 text-[#727272]', {
|
className={cn(
|
||||||
'text-slate-900': period === 'MONTHLY',
|
'text-muted-foreground relative flex items-center gap-x-2.5 px-1 py-2.5',
|
||||||
'hover:text-slate-900/80': period !== 'MONTHLY',
|
{
|
||||||
})}
|
'text-foreground': period === 'MONTHLY',
|
||||||
|
'hover:text-foreground/80': period !== 'MONTHLY',
|
||||||
|
},
|
||||||
|
)}
|
||||||
onClick={() => setPeriod('MONTHLY')}
|
onClick={() => setPeriod('MONTHLY')}
|
||||||
>
|
>
|
||||||
Monthly
|
Monthly
|
||||||
@ -58,14 +61,17 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
key="YEARLY"
|
key="YEARLY"
|
||||||
className={cn('relative flex items-center gap-x-2.5 px-1 py-2.5 text-[#727272]', {
|
className={cn(
|
||||||
'text-slate-900': period === 'YEARLY',
|
'text-muted-foreground relative flex items-center gap-x-2.5 px-1 py-2.5',
|
||||||
'hover:text-slate-900/80': period !== 'YEARLY',
|
{
|
||||||
})}
|
'text-foreground': period === 'YEARLY',
|
||||||
|
'hover:text-foreground/80': period !== 'YEARLY',
|
||||||
|
},
|
||||||
|
)}
|
||||||
onClick={() => setPeriod('YEARLY')}
|
onClick={() => setPeriod('YEARLY')}
|
||||||
>
|
>
|
||||||
Yearly
|
Yearly
|
||||||
<div className="block rounded-full bg-slate-200 px-2 py-0.5 text-xs text-slate-700">
|
<div className="bg-muted text-foreground block rounded-full px-2 py-0.5 text-xs">
|
||||||
Save $60
|
Save $60
|
||||||
</div>
|
</div>
|
||||||
{period === 'YEARLY' && (
|
{period === 'YEARLY' && (
|
||||||
@ -81,12 +87,12 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
<div className="mt-12 grid grid-cols-1 gap-x-6 gap-y-12 md:grid-cols-2 lg:grid-cols-3">
|
<div className="mt-12 grid grid-cols-1 gap-x-6 gap-y-12 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div
|
<div
|
||||||
data-plan="self-hosted"
|
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"
|
className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg"
|
||||||
>
|
>
|
||||||
<p className="text-4xl font-medium text-slate-900">Self Hosted</p>
|
<p className="text-foreground text-4xl font-medium">Self Hosted</p>
|
||||||
<p className="text-primary mt-2.5 text-xl font-medium">Free</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">
|
<p className="text-foreground mt-4 max-w-[30ch] text-center">
|
||||||
For small teams and individuals who need a simple solution
|
For small teams and individuals who need a simple solution
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -100,20 +106,20 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
<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="text-foreground py-4 font-medium">Host your own instance</p>
|
||||||
<p className="py-4 text-slate-900">Full Control</p>
|
<p className="text-foreground py-4">Full Control</p>
|
||||||
<p className="py-4 text-slate-900">Customizability</p>
|
<p className="text-foreground py-4">Customizability</p>
|
||||||
<p className="py-4 text-slate-900">Docker Ready</p>
|
<p className="text-foreground py-4">Docker Ready</p>
|
||||||
<p className="py-4 text-slate-900">Community Support</p>
|
<p className="text-foreground py-4">Community Support</p>
|
||||||
<p className="py-4 text-slate-900">Free, Forever</p>
|
<p className="text-foreground py-4">Free, Forever</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-plan="community"
|
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"
|
className="border-primary bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border-2 px-8 py-12 shadow-[0px_0px_0px_4px_#E3E3E380]"
|
||||||
>
|
>
|
||||||
<p className="text-4xl font-medium text-slate-900">Community</p>
|
<p className="text-foreground text-4xl font-medium">Community</p>
|
||||||
<div className="text-primary mt-2.5 text-xl font-medium">
|
<div className="text-primary mt-2.5 text-xl font-medium">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{period === 'MONTHLY' && <motion.div layoutId="pricing">$30</motion.div>}
|
{period === 'MONTHLY' && <motion.div layoutId="pricing">$30</motion.div>}
|
||||||
@ -121,7 +127,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 max-w-[30ch] text-center text-slate-900">
|
<p className="text-foreground mt-4 max-w-[30ch] text-center">
|
||||||
For fast-growing companies that aim to scale across multiple teams.
|
For fast-growing companies that aim to scale across multiple teams.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -130,25 +136,25 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</ClaimPlanDialog>
|
</ClaimPlanDialog>
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
<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="text-foreground py-4 font-medium">Documenso Early Adopter Deal:</p>
|
||||||
<p className="py-4 text-slate-900">Join the movement</p>
|
<p className="text-foreground py-4">Join the movement</p>
|
||||||
<p className="py-4 text-slate-900">Simple signing solution</p>
|
<p className="text-foreground py-4">Simple signing solution</p>
|
||||||
<p className="py-4 text-slate-900">Email and Slack assistance</p>
|
<p className="text-foreground py-4">Email and Slack assistance</p>
|
||||||
<p className="py-4 text-slate-900">
|
<p className="text-foreground py-4">
|
||||||
<strong>Includes all upcoming features</strong>
|
<strong>Includes all upcoming features</strong>
|
||||||
</p>
|
</p>
|
||||||
<p className="py-4 text-slate-900">Fixed, straightforward pricing</p>
|
<p className="text-foreground py-4">Fixed, straightforward pricing</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-plan="enterprise"
|
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"
|
className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg"
|
||||||
>
|
>
|
||||||
<p className="text-4xl font-medium text-slate-900">Enterprise</p>
|
<p className="text-foreground text-4xl font-medium">Enterprise</p>
|
||||||
<p className="text-primary mt-2.5 text-xl font-medium">Pricing on request</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">
|
<p className="text-foreground mt-4 max-w-[30ch] text-center">
|
||||||
For large organizations that need extra flexibility and control.
|
For large organizations that need extra flexibility and control.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -162,12 +168,12 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
<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="text-foreground py-4 font-medium">Everything in Community, plus:</p>
|
||||||
<p className="py-4 text-slate-900">Custom Subdomain</p>
|
<p className="text-foreground py-4">Custom Subdomain</p>
|
||||||
<p className="py-4 text-slate-900">Compliance Check</p>
|
<p className="text-foreground py-4">Compliance Check</p>
|
||||||
<p className="py-4 text-slate-900">Guaranteed Uptime</p>
|
<p className="text-foreground py-4">Guaranteed Uptime</p>
|
||||||
<p className="py-4 text-slate-900">Reporting & Analysis</p>
|
<p className="text-foreground py-4">Reporting & Analysis</p>
|
||||||
<p className="py-4 text-slate-900">24/7 Support</p>
|
<p className="text-foreground py-4">24/7 Support</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export const ShareConnectPaidWidgetBento = ({
|
|||||||
<Image
|
<Image
|
||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
||||||
@ -34,54 +34,70 @@ export const ShareConnectPaidWidgetBento = ({
|
|||||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
<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>
|
<Card className="col-span-2 lg:col-span-1" degrees={120} gradient>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Easy Sharing (Soon).</strong>
|
<strong className="block">Easy Sharing (Soon).</strong>
|
||||||
Receive your personal link to share with everyone you care about.
|
Receive your personal link to share with everyone you care about.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardSharingFigure} alt="its fast" className="w-full max-w-xs" />
|
<Image
|
||||||
|
src={cardSharingFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-xs dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Connections (Soon).</strong>
|
<strong className="block">Connections (Soon).</strong>
|
||||||
Create connections and automations with Zapier and more to integrate with your
|
Create connections and automations with Zapier and more to integrate with your
|
||||||
favorite tools.
|
favorite tools.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardConnectionsFigure} alt="its fast" className="w-full max-w-sm" />
|
<Image
|
||||||
|
src={cardConnectionsFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-sm dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Get paid (Soon).</strong>
|
<strong className="block">Get paid (Soon).</strong>
|
||||||
Integrated payments with stripe so you don’t have to worry about getting paid.
|
Integrated payments with stripe so you don’t have to worry about getting paid.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardPaidFigure} alt="its fast" className="w-full max-w-[14rem]" />
|
<Image
|
||||||
|
src={cardPaidFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-[14rem] dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">React Widget (Soon).</strong>
|
<strong className="block">React Widget (Soon).</strong>
|
||||||
Easily embed Documenso into your product. Simply copy and paste our react widget into
|
Easily embed Documenso into your product. Simply copy and paste our react widget into
|
||||||
your application.
|
your application.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardWidgetFigure} alt="its fast" className="w-full max-w-xs" />
|
<Image
|
||||||
|
src={cardWidgetFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-xs dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -0,0 +1,233 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { render } from '@documenso/email/render';
|
||||||
|
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
|
||||||
|
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
|
||||||
|
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
|
||||||
|
import { alphaid } from '@documenso/lib/universal/id';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import {
|
||||||
|
DocumentDataType,
|
||||||
|
DocumentStatus,
|
||||||
|
FieldType,
|
||||||
|
Prisma,
|
||||||
|
ReadStatus,
|
||||||
|
SendStatus,
|
||||||
|
SigningStatus,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
const ZCreateSinglePlayerDocumentSchema = z.object({
|
||||||
|
documentData: z.object({
|
||||||
|
data: z.string(),
|
||||||
|
type: z.nativeEnum(DocumentDataType),
|
||||||
|
}),
|
||||||
|
documentName: z.string(),
|
||||||
|
signer: z.object({
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
name: z.string(),
|
||||||
|
signature: z.string(),
|
||||||
|
}),
|
||||||
|
fields: z.array(
|
||||||
|
z.object({
|
||||||
|
page: z.number(),
|
||||||
|
type: z.nativeEnum(FieldType),
|
||||||
|
positionX: z.number(),
|
||||||
|
positionY: z.number(),
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateSinglePlayerDocumentSchema = z.infer<typeof ZCreateSinglePlayerDocumentSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and self signs a document.
|
||||||
|
*
|
||||||
|
* Returns the document token.
|
||||||
|
*/
|
||||||
|
export const createSinglePlayerDocument = async (
|
||||||
|
value: TCreateSinglePlayerDocumentSchema,
|
||||||
|
): Promise<string> => {
|
||||||
|
const { signer, fields, documentData, documentName } =
|
||||||
|
ZCreateSinglePlayerDocumentSchema.parse(value);
|
||||||
|
|
||||||
|
const document = await getFile({
|
||||||
|
data: documentData.data,
|
||||||
|
type: documentData.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = await PDFDocument.load(document);
|
||||||
|
const createdAt = new Date();
|
||||||
|
|
||||||
|
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
|
||||||
|
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
|
||||||
|
const typedSignature = !isBase64 ? signer.signature : null;
|
||||||
|
|
||||||
|
// Update the document with the fields inserted.
|
||||||
|
for (const field of fields) {
|
||||||
|
const isSignatureField = field.type === FieldType.SIGNATURE;
|
||||||
|
|
||||||
|
await insertFieldInPDF(doc, {
|
||||||
|
...mapField(field, signer),
|
||||||
|
Signature: isSignatureField
|
||||||
|
? {
|
||||||
|
created: createdAt,
|
||||||
|
signatureImageAsBase64,
|
||||||
|
typedSignature,
|
||||||
|
// Dummy data.
|
||||||
|
id: -1,
|
||||||
|
recipientId: -1,
|
||||||
|
fieldId: -1,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
// Dummy data.
|
||||||
|
id: -1,
|
||||||
|
documentId: -1,
|
||||||
|
recipientId: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBytes = await doc.save();
|
||||||
|
|
||||||
|
const documentToken = await prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const documentToken = alphaid();
|
||||||
|
|
||||||
|
// Fetch service user who will be the owner of the document.
|
||||||
|
const serviceUser = await tx.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
email: SERVICE_USER_EMAIL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentDataBytes = Buffer.from(pdfBytes).toString('base64');
|
||||||
|
|
||||||
|
const { id: documentDataId } = await tx.documentData.create({
|
||||||
|
data: {
|
||||||
|
type: DocumentDataType.BYTES_64,
|
||||||
|
data: documentDataBytes,
|
||||||
|
initialData: documentDataBytes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create document.
|
||||||
|
const document = await tx.document.create({
|
||||||
|
data: {
|
||||||
|
title: documentName,
|
||||||
|
status: DocumentStatus.COMPLETED,
|
||||||
|
documentDataId,
|
||||||
|
userId: serviceUser.id,
|
||||||
|
createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create recipient.
|
||||||
|
const recipient = await tx.recipient.create({
|
||||||
|
data: {
|
||||||
|
documentId: document.id,
|
||||||
|
name: signer.name,
|
||||||
|
email: signer.email,
|
||||||
|
token: documentToken,
|
||||||
|
signedAt: createdAt,
|
||||||
|
readStatus: ReadStatus.OPENED,
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create fields and signatures.
|
||||||
|
await Promise.all(
|
||||||
|
fields.map(async (field) => {
|
||||||
|
const insertedField = await tx.field.create({
|
||||||
|
data: {
|
||||||
|
documentId: document.id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
...mapField(field, signer),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
||||||
|
await tx.signature.create({
|
||||||
|
data: {
|
||||||
|
fieldId: insertedField.id,
|
||||||
|
signatureImageAsBase64,
|
||||||
|
typedSignature,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return documentToken;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxWait: 5000,
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const template = createElement(DocumentSelfSignedEmailTemplate, {
|
||||||
|
documentName: documentName,
|
||||||
|
assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email to signer.
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: signer.email,
|
||||||
|
name: signer.name,
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: FROM_NAME,
|
||||||
|
address: FROM_ADDRESS,
|
||||||
|
},
|
||||||
|
subject: 'Document signed',
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
attachments: [{ content: Buffer.from(pdfBytes), filename: documentName }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return documentToken;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the fields provided by the user to fields compatible with Prisma.
|
||||||
|
*
|
||||||
|
* Signature fields are handled separately.
|
||||||
|
*
|
||||||
|
* @param field The field passed in by the user.
|
||||||
|
* @param signer The details of the person who is signing this document.
|
||||||
|
* @returns A field compatible with Prisma.
|
||||||
|
*/
|
||||||
|
const mapField = (
|
||||||
|
field: TCreateSinglePlayerDocumentSchema['fields'][number],
|
||||||
|
signer: TCreateSinglePlayerDocumentSchema['signer'],
|
||||||
|
) => {
|
||||||
|
const customText = match(field.type)
|
||||||
|
.with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a'))
|
||||||
|
.with(FieldType.EMAIL, () => signer.email)
|
||||||
|
.with(FieldType.NAME, () => signer.name)
|
||||||
|
.otherwise(() => '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: field.type,
|
||||||
|
page: field.page,
|
||||||
|
positionX: new Prisma.Decimal(field.positionX),
|
||||||
|
positionY: new Prisma.Decimal(field.positionY),
|
||||||
|
width: new Prisma.Decimal(field.width),
|
||||||
|
height: new Prisma.Decimal(field.height),
|
||||||
|
customText,
|
||||||
|
inserted: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Share } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
|
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||||
|
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
||||||
|
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||||
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import signingCelebration from '~/assets/signing-celebration.png';
|
||||||
|
import ConfettiScreen from '~/components/(marketing)/confetti-screen';
|
||||||
|
|
||||||
|
import { DocumentStatus } from '.prisma/client';
|
||||||
|
|
||||||
|
interface SinglePlayerModeSuccessProps {
|
||||||
|
className?: string;
|
||||||
|
document: DocumentWithRecipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerModeSuccessProps) => {
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const isConfettiEnabled = getFlag('marketing_spm_confetti');
|
||||||
|
|
||||||
|
const [showDocumentDialog, setShowDocumentDialog] = useState(false);
|
||||||
|
const [isFetchingDocumentFile, setIsFetchingDocumentFile] = useState(false);
|
||||||
|
const [documentFile, setDocumentFile] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const onShowDocumentClick = async () => {
|
||||||
|
if (isFetchingDocumentFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFetchingDocumentFile(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getFile(document.documentData);
|
||||||
|
|
||||||
|
setDocumentFile(base64.encode(data));
|
||||||
|
|
||||||
|
setShowDocumentDialog(true);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong.',
|
||||||
|
description: 'We were unable to retrieve the document at this time. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFetchingDocumentFile(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[calc(100vh-10rem)] flex-col items-center justify-center sm:min-h-[calc(100vh-13rem)]">
|
||||||
|
{isConfettiEnabled && (
|
||||||
|
<ConfettiScreen duration={3000} gravity={0.075} initialVelocityY={50} wind={0.005} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className="relative z-10 text-center text-2xl font-semibold leading-normal md:text-3xl lg:mb-2 lg:text-4xl">
|
||||||
|
You have signed
|
||||||
|
<span className="mt-2 block">{document.title}</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<SigningCard3D
|
||||||
|
className="mt-8"
|
||||||
|
name={document.Recipient.name || document.Recipient.email}
|
||||||
|
signingCelebrationImage={signingCelebration}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative mt-8 w-full">
|
||||||
|
<div className={cn('flex flex-col items-center', className)}>
|
||||||
|
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
|
||||||
|
{/* TODO: Hook this up */}
|
||||||
|
<Button variant="outline" className="flex-1 bg-transparent backdrop-blur-sm" disabled>
|
||||||
|
<Share className="mr-2 h-5 w-5" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DocumentDownloadButton
|
||||||
|
className="flex-1 bg-transparent backdrop-blur-sm"
|
||||||
|
fileName={document.title}
|
||||||
|
documentData={document.documentData}
|
||||||
|
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={async () => onShowDocumentClick()}
|
||||||
|
loading={isFetchingDocumentFile}
|
||||||
|
className="col-span-2"
|
||||||
|
>
|
||||||
|
Show document
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground/60 mt-16 text-center text-sm">
|
||||||
|
Create a{' '}
|
||||||
|
<Link
|
||||||
|
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
|
||||||
|
target="_blank"
|
||||||
|
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
free account
|
||||||
|
</Link>{' '}
|
||||||
|
to access your signed documents at any time
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DocumentDialog
|
||||||
|
document={documentFile ?? ''}
|
||||||
|
open={showDocumentDialog}
|
||||||
|
onOpenChange={setShowDocumentDialog}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -181,16 +181,16 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-12 gap-y-8 overflow-hidden p-2 lg:gap-x-8">
|
<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">
|
<div className="text-muted-foreground col-span-12 flex flex-col gap-y-4 p-4 text-xs leading-relaxed lg:col-span-7">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="col-span-12 flex flex-col rounded-2xl bg-[#F7F7F7] p-6 lg:col-span-5"
|
className="bg-foreground/5 col-span-12 flex flex-col rounded-2xl p-6 lg:col-span-5"
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<h3 className="text-2xl font-semibold">Sign up for the community plan</h3>
|
<h3 className="text-2xl font-semibold">Sign up for the community plan</h3>
|
||||||
<p className="mt-2 text-xs text-[#AFAFAF]">
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
with Timur Ercan & Lucas Smith from Documenso
|
with Timur Ercan & Lucas Smith from Documenso
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -198,7 +198,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div key="email">
|
<motion.div key="email">
|
||||||
<label htmlFor="email" className="text-lg font-semibold text-slate-900 lg:text-xl">
|
<label htmlFor="email" className="text-foreground text-lg font-semibold lg:text-xl">
|
||||||
What’s your email?
|
What’s your email?
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@ -211,7 +211,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder=""
|
placeholder=""
|
||||||
className="w-full bg-white pr-16"
|
className="bg-background w-full pr-16"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onKeyDown={(e) =>
|
onKeyDown={(e) =>
|
||||||
field.value !== '' &&
|
field.value !== '' &&
|
||||||
@ -255,7 +255,10 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
transform: 'translateX(25%)',
|
transform: 'translateX(25%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label htmlFor="name" className="text-lg font-semibold text-slate-900 lg:text-xl">
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="text-foreground text-lg font-semibold lg:text-xl"
|
||||||
|
>
|
||||||
and your name?
|
and your name?
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@ -268,7 +271,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder=""
|
placeholder=""
|
||||||
className="w-full bg-white pr-16"
|
className="bg-background w-full pr-16"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onKeyDown={(e) =>
|
onKeyDown={(e) =>
|
||||||
field.value !== '' &&
|
field.value !== '' &&
|
||||||
@ -300,11 +303,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
<div className="mt-12 flex-1" />
|
<div className="mt-12 flex-1" />
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-[#AFAFAF]">{stepsRemaining} step(s) until signed</p>
|
<p className="text-muted-foreground text-xs">{stepsRemaining} step(s) until signed</p>
|
||||||
<p className="block text-xs text-[#AFAFAF] md:hidden">Minimise contract</p>
|
<p className="text-muted-foreground block text-xs md:hidden">Minimise contract</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mt-2.5 h-[2px] w-full bg-[#E9E9E9]">
|
<div className="bg-background relative mt-2.5 h-[2px] w-full">
|
||||||
<div
|
<div
|
||||||
className={cn('bg-primary/60 absolute inset-y-0 left-0 duration-200', {
|
className={cn('bg-primary/60 absolute inset-y-0 left-0 duration-200', {
|
||||||
'w-1/3': stepsRemaining === 3,
|
'w-1/3': stepsRemaining === 3,
|
||||||
@ -322,13 +325,17 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
>
|
>
|
||||||
<div className="flex h-28 items-center justify-center pb-6">
|
<div className="flex h-28 items-center justify-center pb-6">
|
||||||
{!signatureText && signatureDataUrl && (
|
{!signatureText && signatureDataUrl && (
|
||||||
<img src={signatureDataUrl} alt="user signature" className="h-full" />
|
<img
|
||||||
|
src={signatureDataUrl}
|
||||||
|
alt="user signature"
|
||||||
|
className="h-full dark:invert"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{signatureText && (
|
{signatureText && (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-4xl font-semibold text-slate-900 [font-family:var(--font-caveat)]',
|
'text-foreground text-4xl font-semibold [font-family:var(--font-caveat)]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{signatureText}
|
{signatureText}
|
||||||
@ -342,7 +349,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="signatureText"
|
id="signatureText"
|
||||||
className="border-none p-0 text-sm text-slate-700 placeholder:text-[#D6D6D6] focus-visible:ring-0"
|
className="text-foreground placeholder:text-muted-foreground border-none p-0 text-sm focus-visible:ring-0"
|
||||||
placeholder="Draw or type name here"
|
placeholder="Draw or type name here"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...register('signatureText', {
|
{...register('signatureText', {
|
||||||
@ -356,7 +363,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="h-8 disabled:bg-[#ECEEED] disabled:text-[#C6C6C6] disabled:hover:bg-[#ECEEED]"
|
className="disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted h-8"
|
||||||
disabled={!isValid || isSubmitting}
|
disabled={!isValid || isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
|
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export default async function handler(
|
|||||||
|
|
||||||
if (user && user.Subscription.length > 0) {
|
if (user && user.Subscription.length > 0) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,8 +103,8 @@ export default async function handler(
|
|||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
metadata,
|
metadata,
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/pricing?email=${encodeURIComponent(
|
||||||
email,
|
email,
|
||||||
)}&name=${encodeURIComponent(name)}&planId=${planId}&cancelled=true`,
|
)}&name=${encodeURIComponent(name)}&planId=${planId}&cancelled=true`,
|
||||||
});
|
});
|
||||||
|
|||||||
7
apps/marketing/src/pages/api/feature-flag/all.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
runtime: 'edge',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handlerFeatureFlagAll;
|
||||||
7
apps/marketing/src/pages/api/feature-flag/get.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
runtime: 'edge',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handlerFeatureFlagGet;
|
||||||
@ -8,8 +8,11 @@ import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in
|
|||||||
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
|
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
|
||||||
import { redis } from '@documenso/lib/server-only/redis';
|
import { redis } from '@documenso/lib/server-only/redis';
|
||||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
|
import { updateFile } from '@documenso/lib/universal/upload/update-file';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import {
|
import {
|
||||||
|
DocumentDataType,
|
||||||
DocumentStatus,
|
DocumentStatus,
|
||||||
FieldType,
|
FieldType,
|
||||||
ReadStatus,
|
ReadStatus,
|
||||||
@ -85,16 +88,34 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
|
||||||
|
|
||||||
|
const { id: documentDataId } = await prisma.documentData.create({
|
||||||
|
data: {
|
||||||
|
type: DocumentDataType.BYTES_64,
|
||||||
|
data: bytes64,
|
||||||
|
initialData: bytes64,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const document = await prisma.document.create({
|
const document = await prisma.document.create({
|
||||||
data: {
|
data: {
|
||||||
title: 'Documenso Supporter Pledge.pdf',
|
title: 'Documenso Supporter Pledge.pdf',
|
||||||
status: DocumentStatus.COMPLETED,
|
status: DocumentStatus.COMPLETED,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
document: readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'),
|
documentDataId,
|
||||||
created: now,
|
},
|
||||||
|
include: {
|
||||||
|
documentData: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { documentData } = document;
|
||||||
|
|
||||||
|
if (!documentData) {
|
||||||
|
throw new Error(`Document ${document.id} has no document data`);
|
||||||
|
}
|
||||||
|
|
||||||
const recipient = await prisma.recipient.create({
|
const recipient = await prisma.recipient.create({
|
||||||
data: {
|
data: {
|
||||||
name: user.name ?? '',
|
name: user.name ?? '',
|
||||||
@ -121,17 +142,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let pdfData = await getFile(documentData).then((data) =>
|
||||||
|
Buffer.from(data).toString('base64'),
|
||||||
|
);
|
||||||
|
|
||||||
if (signatureDataUrl) {
|
if (signatureDataUrl) {
|
||||||
document.document = await insertImageInPDF(
|
pdfData = await insertImageInPDF(
|
||||||
document.document,
|
pdfData,
|
||||||
signatureDataUrl,
|
signatureDataUrl,
|
||||||
Number(field.positionX),
|
Number(field.positionX),
|
||||||
Number(field.positionY),
|
Number(field.positionY),
|
||||||
field.page,
|
field.page,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
document.document = await insertTextInPDF(
|
pdfData = await insertTextInPDF(
|
||||||
document.document,
|
pdfData,
|
||||||
signatureText ?? '',
|
signatureText ?? '',
|
||||||
Number(field.positionX),
|
Number(field.positionX),
|
||||||
Number(field.positionY),
|
Number(field.positionY),
|
||||||
@ -139,6 +164,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { data: newData } = await updateFile({
|
||||||
|
type: documentData.type,
|
||||||
|
oldData: documentData.initialData,
|
||||||
|
newData: Buffer.from(pdfData, 'base64').toString('binary'),
|
||||||
|
});
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.signature.create({
|
prisma.signature.create({
|
||||||
data: {
|
data: {
|
||||||
@ -148,12 +179,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
typedSignature: signatureDataUrl ? '' : signatureText,
|
typedSignature: signatureDataUrl ? '' : signatureText,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.document.update({
|
prisma.documentData.update({
|
||||||
where: {
|
where: {
|
||||||
id: document.id,
|
id: documentData.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
document: document.document,
|
data: newData,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
10
apps/marketing/src/providers/next-theme.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
|
import { ThemeProviderProps } from 'next-themes/dist/types';
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
39
apps/marketing/src/providers/posthog.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import posthog from 'posthog-js';
|
||||||
|
|
||||||
|
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||||
|
|
||||||
|
export function PostHogPageview() {
|
||||||
|
const postHogConfig = extractPostHogConfig();
|
||||||
|
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && postHogConfig) {
|
||||||
|
posthog.init(postHogConfig.key, {
|
||||||
|
api_host: postHogConfig.host,
|
||||||
|
disable_session_recording: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!postHogConfig || !pathname) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = window.origin + pathname;
|
||||||
|
if (searchParams && searchParams.toString()) {
|
||||||
|
url = url + `?${searchParams.toString()}`;
|
||||||
|
}
|
||||||
|
posthog.capture('$pageview', {
|
||||||
|
$current_url: url,
|
||||||
|
});
|
||||||
|
}, [pathname, searchParams, postHogConfig]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
BIN
apps/web/example/cert.p12
Normal file
@ -1,7 +1,8 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { version } = require('./package.json');
|
||||||
|
|
||||||
const { parsed: env } = require('dotenv').config({
|
require('dotenv').config({
|
||||||
path: path.join(__dirname, '../../.env.local'),
|
path: path.join(__dirname, '../../.env.local'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -9,6 +10,7 @@ const { parsed: env } = require('dotenv').config({
|
|||||||
const config = {
|
const config = {
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: true,
|
serverActions: true,
|
||||||
|
serverActionsBodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
@ -18,7 +20,10 @@ const config = {
|
|||||||
'@documenso/ui',
|
'@documenso/ui',
|
||||||
'@documenso/email',
|
'@documenso/email',
|
||||||
],
|
],
|
||||||
env,
|
env: {
|
||||||
|
APP_VERSION: version,
|
||||||
|
NEXT_PUBLIC_PROJECT: 'web',
|
||||||
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
"clean": "rimraf .next && rimraf node_modules",
|
||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -21,11 +22,10 @@
|
|||||||
"@tanstack/react-query": "^4.29.5",
|
"@tanstack/react-query": "^4.29.5",
|
||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.214.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"nanoid": "^4.0.2",
|
"next": "13.4.19",
|
||||||
"next": "13.4.12",
|
|
||||||
"next-auth": "4.22.3",
|
"next-auth": "4.22.3",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
|
|||||||
3
apps/web/process-env.d.ts
vendored
@ -1,6 +1,7 @@
|
|||||||
declare namespace NodeJS {
|
declare namespace NodeJS {
|
||||||
export interface ProcessEnv {
|
export interface ProcessEnv {
|
||||||
NEXT_PUBLIC_SITE_URL?: string;
|
NEXT_PUBLIC_WEBAPP_URL?: string;
|
||||||
|
NEXT_PUBLIC_MARKETING_URL?: string;
|
||||||
|
|
||||||
NEXT_PRIVATE_DATABASE_URL: string;
|
NEXT_PRIVATE_DATABASE_URL: string;
|
||||||
|
|
||||||
|
|||||||
BIN
apps/web/public/fonts/caveat-regular.ttf
Normal file
BIN
apps/web/public/fonts/inter-bold.ttf
Normal file
BIN
apps/web/public/fonts/inter-regular.ttf
Normal file
BIN
apps/web/public/fonts/inter-semibold.ttf
Normal file
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
BIN
apps/web/public/static/og-share-frame.png
Normal file
|
After Width: | Height: | Size: 743 KiB |
BIN
apps/web/public/static/user-plus.png
Normal file
|
After Width: | Height: | Size: 367 B |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 629 B |
@ -1,34 +0,0 @@
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import { TCreateDocumentRequestSchema, ZCreateDocumentResponseSchema } from './types';
|
|
||||||
|
|
||||||
export const useCreateDocument = () => {
|
|
||||||
return useMutation(async ({ file }: TCreateDocumentRequestSchema) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.set('file', file);
|
|
||||||
|
|
||||||
const response = await fetch('/api/document/create', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = await response.json();
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
throw new Error('Failed to create document');
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeBody = ZCreateDocumentResponseSchema.safeParse(body);
|
|
||||||
|
|
||||||
if (!safeBody.success) {
|
|
||||||
throw new Error('Failed to create document');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('error' in safeBody.data) {
|
|
||||||
throw new Error(safeBody.data.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return safeBody.data;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const ZCreateDocumentRequestSchema = z.object({
|
|
||||||
file: z.instanceof(File),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TCreateDocumentRequestSchema = z.infer<typeof ZCreateDocumentRequestSchema>;
|
|
||||||
|
|
||||||
export const ZCreateDocumentResponseSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.number(),
|
|
||||||
})
|
|
||||||
.or(
|
|
||||||
z.object({
|
|
||||||
error: z.string(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TCreateDocumentResponseSchema = z.infer<typeof ZCreateDocumentResponseSchema>;
|
|
||||||
30
apps/web/src/app/(dashboard)/admin/layout.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
|
|
||||||
|
import { AdminNav } from './nav';
|
||||||
|
|
||||||
|
export type AdminSectionLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
|
||||||
|
const user = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
if (!isAdmin(user)) {
|
||||||
|
redirect('/documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto mt-16 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<div className="grid grid-cols-12 gap-x-8 md:mt-8">
|
||||||
|
<AdminNav className="col-span-12 md:col-span-3 md:flex" />
|
||||||
|
|
||||||
|
<div className="col-span-12 mt-12 md:col-span-9 md:mt-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
apps/web/src/app/(dashboard)/admin/nav.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { BarChart3, User2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export type AdminNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex gap-x-2.5 gap-y-2 md:flex-col', className)} {...props}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'justify-start md:w-full',
|
||||||
|
pathname?.startsWith('/admin/stats') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/admin/stats">
|
||||||
|
<BarChart3 className="mr-2 h-5 w-5" />
|
||||||
|
Stats
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'justify-start md:w-full',
|
||||||
|
pathname?.startsWith('/admin/users') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<User2 className="mr-2 h-5 w-5" />
|
||||||
|
Users (Coming Soon)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
apps/web/src/app/(dashboard)/admin/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
redirect('/admin/stats');
|
||||||
|
}
|
||||||
75
apps/web/src/app/(dashboard)/admin/stats/page.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
File,
|
||||||
|
FileCheck,
|
||||||
|
FileClock,
|
||||||
|
FileEdit,
|
||||||
|
Mail,
|
||||||
|
MailOpen,
|
||||||
|
PenTool,
|
||||||
|
User as UserIcon,
|
||||||
|
UserPlus2,
|
||||||
|
UserSquare2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||||
|
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||||
|
import {
|
||||||
|
getUsersCount,
|
||||||
|
getUsersWithSubscriptionsCount,
|
||||||
|
} from '@documenso/lib/server-only/admin/get-users-stats';
|
||||||
|
|
||||||
|
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||||
|
|
||||||
|
export default async function AdminStatsPage() {
|
||||||
|
const [usersCount, usersWithSubscriptionsCount, docStats, recipientStats] = await Promise.all([
|
||||||
|
getUsersCount(),
|
||||||
|
getUsersWithSubscriptionsCount(),
|
||||||
|
getDocumentStats(),
|
||||||
|
getRecipientsStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-4xl font-semibold">Instance Stats</h2>
|
||||||
|
|
||||||
|
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<CardMetric icon={UserIcon} title="Total Users" value={usersCount} />
|
||||||
|
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
|
||||||
|
<CardMetric
|
||||||
|
icon={UserPlus2}
|
||||||
|
title="Active Subscriptions"
|
||||||
|
value={usersWithSubscriptionsCount}
|
||||||
|
/>
|
||||||
|
<CardMetric icon={UserPlus2} title="App Version" value={`v${process.env.APP_VERSION}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-3xl font-semibold">Document metrics</h3>
|
||||||
|
|
||||||
|
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
|
||||||
|
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
|
||||||
|
<CardMetric icon={FileEdit} title="Drafted Documents" value={docStats.DRAFT} />
|
||||||
|
<CardMetric icon={FileClock} title="Pending Documents" value={docStats.PENDING} />
|
||||||
|
<CardMetric icon={FileCheck} title="Completed Documents" value={docStats.COMPLETED} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
|
||||||
|
|
||||||
|
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
|
||||||
|
<CardMetric
|
||||||
|
icon={UserSquare2}
|
||||||
|
title="Total Recipients"
|
||||||
|
value={recipientStats.TOTAL_RECIPIENTS}
|
||||||
|
/>
|
||||||
|
<CardMetric icon={Mail} title="Documents Received" value={recipientStats.SENT} />
|
||||||
|
<CardMetric icon={MailOpen} title="Documents Viewed" value={recipientStats.OPENED} />
|
||||||
|
<CardMetric icon={PenTool} title="Signatures Collected" value={recipientStats.SIGNED} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,124 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Clock, File, FileCheck } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
|
||||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
|
||||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
|
||||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@documenso/ui/primitives/table';
|
|
||||||
|
|
||||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
|
||||||
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
import { UploadDocument } from './upload-document';
|
|
||||||
|
|
||||||
const CARD_DATA = [
|
|
||||||
{
|
|
||||||
icon: FileCheck,
|
|
||||||
title: 'Completed',
|
|
||||||
status: InternalDocumentStatus.COMPLETED,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: File,
|
|
||||||
title: 'Drafts',
|
|
||||||
status: InternalDocumentStatus.DRAFT,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Clock,
|
|
||||||
title: 'Pending',
|
|
||||||
status: InternalDocumentStatus.PENDING,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
|
||||||
const user = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const [stats, results] = await Promise.all([
|
|
||||||
getStats({
|
|
||||||
user,
|
|
||||||
}),
|
|
||||||
findDocuments({
|
|
||||||
userId: user.id,
|
|
||||||
perPage: 10,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
<h1 className="text-4xl font-semibold">Dashboard</h1>
|
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
||||||
{CARD_DATA.map((card) => (
|
|
||||||
<Link key={card.status} href={`/documents?status=${card.status}`}>
|
|
||||||
<CardMetric icon={card.icon} title={card.title} value={stats[card.status]} />
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12">
|
|
||||||
<UploadDocument />
|
|
||||||
|
|
||||||
<h2 className="mt-8 text-2xl font-semibold">Recent Documents</h2>
|
|
||||||
|
|
||||||
<div className="border-border mt-8 overflow-x-auto rounded-lg border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-[100px]">ID</TableHead>
|
|
||||||
<TableHead>Title</TableHead>
|
|
||||||
<TableHead>Reciepient</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead className="text-right">Created</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{results.data.map((document) => {
|
|
||||||
return (
|
|
||||||
<TableRow key={document.id}>
|
|
||||||
<TableCell className="font-medium">{document.id}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Link
|
|
||||||
href={`/documents/${document.id}`}
|
|
||||||
className="focus-visible:ring-ring ring-offset-background rounded-md font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
{document.title}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<StackAvatarsWithTooltip recipients={document.Recipient} />
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<DocumentStatus status={document.status} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<LocaleDate date={document.created} />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{results.data.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={4} className="h-24 text-center">
|
|
||||||
No results.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,7 +4,8 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
import { Field, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
@ -28,9 +29,10 @@ import { completeDocument } from '~/components/forms/edit-document/add-subject.a
|
|||||||
export type EditDocumentFormProps = {
|
export type EditDocumentFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
user: User;
|
||||||
document: Document;
|
document: DocumentWithData;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
dataUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
||||||
@ -41,34 +43,30 @@ export const EditDocumentForm = ({
|
|||||||
recipients,
|
recipients,
|
||||||
fields,
|
fields,
|
||||||
user: _user,
|
user: _user,
|
||||||
|
dataUrl,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [step, setStep] = useState<EditDocumentStep>('signers');
|
const [step, setStep] = useState<EditDocumentStep>('signers');
|
||||||
|
|
||||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
|
||||||
|
|
||||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
signers: {
|
signers: {
|
||||||
title: 'Add Signers',
|
title: 'Add Signers',
|
||||||
description: 'Add the people who will sign the document.',
|
description: 'Add the people who will sign the document.',
|
||||||
stepIndex: 1,
|
stepIndex: 1,
|
||||||
onSubmit: () => onAddSignersFormSubmit,
|
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Add Fields',
|
title: 'Add Fields',
|
||||||
description: 'Add all relevant fields for each recipient.',
|
description: 'Add all relevant fields for each recipient.',
|
||||||
stepIndex: 2,
|
stepIndex: 2,
|
||||||
onBackStep: () => setStep('signers'),
|
onBackStep: () => setStep('signers'),
|
||||||
onSubmit: () => onAddFieldsFormSubmit,
|
|
||||||
},
|
},
|
||||||
subject: {
|
subject: {
|
||||||
title: 'Add Subject',
|
title: 'Add Subject',
|
||||||
description: 'Add the subject and message you wish to send to signers.',
|
description: 'Add the subject and message you wish to send to signers.',
|
||||||
stepIndex: 3,
|
stepIndex: 3,
|
||||||
onBackStep: () => setStep('fields'),
|
onBackStep: () => setStep('fields'),
|
||||||
onSubmit: () => onAddSubjectFormSubmit,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -136,7 +134,7 @@ export const EditDocumentForm = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push('/dashboard');
|
router.push('/documents');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@ -151,11 +149,11 @@ export const EditDocumentForm = ({
|
|||||||
return (
|
return (
|
||||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||||
<Card
|
<Card
|
||||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer document={documentUrl} />
|
<LazyPDFViewer document={dataUrl} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -168,6 +166,7 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
{step === 'signers' && (
|
{step === 'signers' && (
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
@ -178,6 +177,7 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
{step === 'fields' && (
|
{step === 'fields' && (
|
||||||
<AddFieldsFormPartial
|
<AddFieldsFormPartial
|
||||||
|
key={fields.length}
|
||||||
documentFlow={documentFlow.fields}
|
documentFlow={documentFlow.fields}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
|
||||||
import { PDFViewerProps } from '@documenso/ui/primitives/pdf-viewer';
|
|
||||||
|
|
||||||
export type LoadablePDFCard = PDFViewerProps & {
|
|
||||||
className?: string;
|
|
||||||
pdfClassName?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
|
|
||||||
return (
|
|
||||||
<Card className={className} gradient {...props}>
|
|
||||||
<CardContent className="p-2">
|
|
||||||
<LazyPDFViewer className={pdfClassName} {...props} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -7,6 +7,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
|||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
@ -36,10 +37,16 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
userId: session.id,
|
userId: session.id,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!document) {
|
if (!document || !document.documentData) {
|
||||||
redirect('/documents');
|
redirect('/documents');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { documentData } = document;
|
||||||
|
|
||||||
|
const documentDataUrl = await getFile(documentData)
|
||||||
|
.then((buffer) => Buffer.from(buffer).toString('base64'))
|
||||||
|
.then((data) => `data:application/pdf;base64,${data}`);
|
||||||
|
|
||||||
const [recipients, fields] = await Promise.all([
|
const [recipients, fields] = await Promise.all([
|
||||||
await getRecipientsForDocument({
|
await getRecipientsForDocument({
|
||||||
documentId,
|
documentId,
|
||||||
@ -86,12 +93,13 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
user={session}
|
user={session}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
dataUrl={documentDataUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||||
<div className="mx-auto mt-12 max-w-2xl">
|
<div className="mx-auto mt-12 max-w-2xl">
|
||||||
<LazyPDFViewer document={`data:application/pdf;base64,${document.document}`} />
|
<LazyPDFViewer document={documentDataUrl} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,7 +7,11 @@ import { useSession } from 'next-auth/react';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
|
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
||||||
|
|
||||||
export type DataTableActionButtonProps = {
|
export type DataTableActionButtonProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
@ -18,11 +22,16 @@ export type DataTableActionButtonProps = {
|
|||||||
|
|
||||||
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [, copyToClipboard] = useCopyToClipboard();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
|
||||||
|
trpc.shareLink.createOrGetShareLink.useMutation();
|
||||||
|
|
||||||
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||||
|
|
||||||
const isOwner = row.User.id === session.user.id;
|
const isOwner = row.User.id === session.user.id;
|
||||||
@ -32,6 +41,20 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
|
||||||
|
const onShareClick = async () => {
|
||||||
|
const { slug } = await createOrGetShareLink({
|
||||||
|
token: recipient?.token,
|
||||||
|
documentId: row.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Copied to clipboard',
|
||||||
|
description: 'The sharing link has been copied to your clipboard.',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isOwner,
|
isOwner,
|
||||||
isRecipient,
|
isRecipient,
|
||||||
@ -57,8 +80,8 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.otherwise(() => (
|
.otherwise(() => (
|
||||||
<Button className="w-24" disabled>
|
<Button className="w-24" loading={isCreatingShareLink} onClick={onShareClick}>
|
||||||
<Share className="-ml-1 mr-2 h-4 w-4" />
|
{!isCreatingShareLink && <Share className="-ml-1 mr-2 h-4 w-4" />}
|
||||||
Share
|
Share
|
||||||
</Button>
|
</Button>
|
||||||
));
|
));
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
History,
|
History,
|
||||||
|
Loader,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
Share,
|
Share,
|
||||||
@ -15,7 +16,11 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -23,6 +28,9 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
||||||
|
|
||||||
export type DataTableActionDropdownProps = {
|
export type DataTableActionDropdownProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
@ -33,11 +41,16 @@ export type DataTableActionDropdownProps = {
|
|||||||
|
|
||||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [, copyToClipboard] = useCopyToClipboard();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
|
||||||
|
trpcReact.shareLink.createOrGetShareLink.useMutation();
|
||||||
|
|
||||||
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||||
|
|
||||||
const isOwner = row.User.id === session.user.id;
|
const isOwner = row.User.id === session.user.id;
|
||||||
@ -47,17 +60,40 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
|
||||||
const onDownloadClick = () => {
|
const onShareClick = async () => {
|
||||||
let decodedDocument = row.document;
|
const { slug } = await createOrGetShareLink({
|
||||||
|
token: recipient?.token,
|
||||||
|
documentId: row.id,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
|
||||||
decodedDocument = atob(decodedDocument);
|
|
||||||
} catch (err) {
|
toast({
|
||||||
// We're just going to ignore this error and try to download the document
|
title: 'Copied to clipboard',
|
||||||
console.error(err);
|
description: 'The sharing link has been copied to your clipboard.',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDownloadClick = async () => {
|
||||||
|
let document: DocumentWithData | null = null;
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
document = await trpcClient.document.getDocumentById.query({
|
||||||
|
id: row.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document = await trpcClient.document.getDocumentByToken.query({
|
||||||
|
token: recipient.token,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
|
const documentData = document?.documentData;
|
||||||
|
|
||||||
|
if (!documentData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentBytes = await getFile(documentData);
|
||||||
|
|
||||||
const blob = new Blob([documentBytes], {
|
const blob = new Blob([documentBytes], {
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
@ -76,20 +112,20 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<MoreHorizontal className="h-5 w-5 text-gray-500" />
|
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!recipient} asChild>
|
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||||
<Link href={`/sign/${recipient?.token}`}>
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
Sign
|
Sign
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner} asChild>
|
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
||||||
<Link href={`/documents/${row.id}`}>
|
<Link href={`/documents/${row.id}`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
@ -123,8 +159,12 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
Resend
|
Resend
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled>
|
<DropdownMenuItem onClick={onShareClick}>
|
||||||
<Share className="mr-2 h-4 w-4" />
|
{isCreatingShareLink ? (
|
||||||
|
<Loader className="mr-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Share className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
Share
|
Share
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
56
apps/web/src/app/(dashboard)/documents/data-table-title.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type DataTableTitleProps = {
|
||||||
|
row: Document & {
|
||||||
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
Recipient: Recipient[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||||
|
|
||||||
|
const isOwner = row.User.id === session.user.id;
|
||||||
|
const isRecipient = !!recipient;
|
||||||
|
|
||||||
|
return match({
|
||||||
|
isOwner,
|
||||||
|
isRecipient,
|
||||||
|
})
|
||||||
|
.with({ isOwner: true }, () => (
|
||||||
|
<Link
|
||||||
|
href={`/documents/${row.id}`}
|
||||||
|
title={row.title}
|
||||||
|
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
.with({ isRecipient: true }, () => (
|
||||||
|
<Link
|
||||||
|
href={`/sign/${recipient?.token}`}
|
||||||
|
title={row.title}
|
||||||
|
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
|
||||||
|
{row.title}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
};
|
||||||
@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
import { useTransition } from 'react';
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
@ -18,6 +17,7 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
|||||||
|
|
||||||
import { DataTableActionButton } from './data-table-action-button';
|
import { DataTableActionButton } from './data-table-action-button';
|
||||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||||
|
import { DataTableTitle } from './data-table-title';
|
||||||
|
|
||||||
export type DocumentsDataTableProps = {
|
export type DocumentsDataTableProps = {
|
||||||
results: FindResultSet<
|
results: FindResultSet<
|
||||||
@ -29,6 +29,7 @@ export type DocumentsDataTableProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
@ -42,25 +43,22 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
header: 'ID',
|
header: 'Created',
|
||||||
accessorKey: 'id',
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Title',
|
header: 'Title',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||||
<Link
|
|
||||||
href={`/documents/${row.original.id}`}
|
|
||||||
title={row.original.title}
|
|
||||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
|
||||||
>
|
|
||||||
{row.original.title}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Recipient',
|
header: 'Recipient',
|
||||||
@ -74,11 +72,6 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
header: 'Created',
|
|
||||||
accessorKey: 'created',
|
|
||||||
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@ -95,12 +88,12 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
totalPages={results.totalPages}
|
totalPages={results.totalPages}
|
||||||
onPaginationChange={onPaginationChange}
|
onPaginationChange={onPaginationChange}
|
||||||
>
|
>
|
||||||
{(table) => <DataTablePagination table={table} />}
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
{isPending && (
|
{isPending && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
||||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
50
apps/web/src/app/(dashboard)/documents/empty-state.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Bird, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
|
||||||
|
export type EmptyDocumentProps = { status: ExtendedDocumentStatus };
|
||||||
|
|
||||||
|
export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
icon: Icon,
|
||||||
|
} = match(status)
|
||||||
|
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
||||||
|
title: 'Nothing to do',
|
||||||
|
message:
|
||||||
|
'There are no completed documents yet. Documents that you have created or received that become completed will appear here later.',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
}))
|
||||||
|
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||||
|
title: 'No active drafts',
|
||||||
|
message:
|
||||||
|
'There are no active drafts at then current moment. You can upload a document to start drafting.',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
}))
|
||||||
|
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||||
|
title: "We're all empty",
|
||||||
|
message:
|
||||||
|
'You have not yet created or received any documents. To create a document please upload one.',
|
||||||
|
icon: Bird,
|
||||||
|
}))
|
||||||
|
.otherwise(() => ({
|
||||||
|
title: 'Nothing to do',
|
||||||
|
message:
|
||||||
|
'All documents are currently actioned. Any new documents are sent or recieved they will start to appear here.',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
|
||||||
|
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
|
|
||||||
|
<p className="mt-2 max-w-[60ch]">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -11,8 +11,9 @@ import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-
|
|||||||
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
import { UploadDocument } from '../dashboard/upload-document';
|
|
||||||
import { DocumentsDataTable } from './data-table';
|
import { DocumentsDataTable } from './data-table';
|
||||||
|
import { EmptyDocumentState } from './empty-state';
|
||||||
|
import { UploadDocument } from './upload-document';
|
||||||
|
|
||||||
export type DocumentsPageProps = {
|
export type DocumentsPageProps = {
|
||||||
searchParams?: {
|
searchParams?: {
|
||||||
@ -39,7 +40,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
status,
|
status,
|
||||||
orderBy: {
|
orderBy: {
|
||||||
column: 'created',
|
column: 'createdAt',
|
||||||
direction: 'desc',
|
direction: 'desc',
|
||||||
},
|
},
|
||||||
page,
|
page,
|
||||||
@ -62,40 +63,44 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<UploadDocument />
|
<UploadDocument />
|
||||||
|
|
||||||
<h1 className="mt-12 text-4xl font-semibold">Documents</h1>
|
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||||
|
<h1 className="text-4xl font-semibold">Documents</h1>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
|
<div className="flex flex-wrap gap-x-4 gap-y-6 overflow-hidden">
|
||||||
<Tabs defaultValue={status} className="overflow-x-auto">
|
<Tabs defaultValue={status} className="overflow-x-auto">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{[
|
{[
|
||||||
ExtendedDocumentStatus.INBOX,
|
ExtendedDocumentStatus.INBOX,
|
||||||
ExtendedDocumentStatus.PENDING,
|
ExtendedDocumentStatus.PENDING,
|
||||||
ExtendedDocumentStatus.COMPLETED,
|
ExtendedDocumentStatus.COMPLETED,
|
||||||
ExtendedDocumentStatus.DRAFT,
|
ExtendedDocumentStatus.DRAFT,
|
||||||
ExtendedDocumentStatus.ALL,
|
ExtendedDocumentStatus.ALL,
|
||||||
].map((value) => (
|
].map((value) => (
|
||||||
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
|
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
|
||||||
<Link href={getTabHref(value)} scroll={false}>
|
<Link href={getTabHref(value)} scroll={false}>
|
||||||
<DocumentStatus status={value} />
|
<DocumentStatus status={value} />
|
||||||
|
|
||||||
{value !== ExtendedDocumentStatus.ALL && (
|
{value !== ExtendedDocumentStatus.ALL && (
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||||
{Math.min(stats[value], 99)}
|
{Math.min(stats[value], 99)}
|
||||||
</span>
|
{stats[value] > 99 && '+'}
|
||||||
)}
|
</span>
|
||||||
</Link>
|
)}
|
||||||
</TabsTrigger>
|
</Link>
|
||||||
))}
|
</TabsTrigger>
|
||||||
</TabsList>
|
))}
|
||||||
</Tabs>
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||||
<PeriodSelector />
|
<PeriodSelector />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<DocumentsDataTable results={results} />
|
{results.count > 0 && <DocumentsDataTable results={results} />}
|
||||||
|
{results.count === 0 && <EmptyDocumentState status={status} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,29 +1,45 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCreateDocument } from '~/api/document/create/fetcher';
|
|
||||||
|
|
||||||
export type UploadDocumentProps = {
|
export type UploadDocumentProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||||
const { toast } = useToast();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { isLoading, mutateAsync: createDocument } = useCreateDocument();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const { type, data } = await putFile(file);
|
||||||
|
|
||||||
|
const { id: documentDataId } = await createDocumentData({
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
const { id } = await createDocument({
|
const { id } = await createDocument({
|
||||||
file: file,
|
title: file.name,
|
||||||
|
documentDataId,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -41,6 +57,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
description: 'An error occurred while uploading your document.',
|
description: 'An error occurred while uploading your document.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -49,8 +67,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
<DocumentDropzone className="min-h-[40vh]" onDrop={onFileDrop} />
|
<DocumentDropzone className="min-h-[40vh]" onDrop={onFileDrop} />
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
||||||
<Loader className="h-12 w-12 animate-spin text-slate-500" />
|
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -4,12 +4,12 @@ import { redirect } from 'next/navigation';
|
|||||||
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
|
|
||||||
|
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
const user = await getRequiredServerComponentSession();
|
const user = await getRequiredServerComponentSession();
|
||||||
@ -21,19 +21,21 @@ export default async function BillingSettingsPage() {
|
|||||||
redirect('/settings/profile');
|
redirect('/settings/profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
let subscription = await getSubscriptionByUserId({ userId: user.id });
|
const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => {
|
||||||
|
if (sub) {
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
// If we don't have a customer record, create one as well as an empty subscription.
|
// If we don't have a customer record, create one as well as an empty subscription.
|
||||||
if (!subscription?.customerId) {
|
return createCustomer({ user });
|
||||||
subscription = await createCustomer({ user });
|
});
|
||||||
}
|
|
||||||
|
|
||||||
let billingPortalUrl = '';
|
let billingPortalUrl = '';
|
||||||
|
|
||||||
if (subscription?.customerId) {
|
if (subscription.customerId) {
|
||||||
billingPortalUrl = await getPortalSession({
|
billingPortalUrl = await getPortalSession({
|
||||||
customerId: subscription.customerId,
|
customerId: subscription.customerId,
|
||||||
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,
|
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,7 +43,7 @@ export default async function BillingSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Billing</h3>
|
<h3 className="text-lg font-medium">Billing</h3>
|
||||||
|
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
Your subscription is{' '}
|
Your subscription is{' '}
|
||||||
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
|
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
|
||||||
{subscription?.periodEnd && (
|
{subscription?.periodEnd && (
|
||||||
@ -65,7 +67,7 @@ export default async function BillingSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!billingPortalUrl && (
|
{!billingPortalUrl && (
|
||||||
<p className="max-w-[60ch] text-base text-slate-500">
|
<p className="text-muted-foreground max-w-[60ch] text-base">
|
||||||
You do not currently have a customer record, this should not happen. Please contact
|
You do not currently have a customer record, this should not happen. Please contact
|
||||||
support for assistance.
|
support for assistance.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export default async function PasswordSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Password</h3>
|
<h3 className="text-lg font-medium">Password</h3>
|
||||||
|
|
||||||
<p className="mt-2 text-sm text-slate-500">Here you can update your password.</p>
|
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export default async function ProfileSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Profile</h3>
|
<h3 className="text-lg font-medium">Profile</h3>
|
||||||
|
|
||||||
<p className="mt-2 text-sm text-slate-500">Here you can edit your personal details.</p>
|
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
|||||||
153
apps/web/src/app/(share)/share/[slug]/opengraph/route.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { ImageResponse, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/share/get-recipient-or-sender-by-share-link-slug';
|
||||||
|
|
||||||
|
import { Logo } from '~/components/branding/logo';
|
||||||
|
import { getAssetBuffer } from '~/helpers/get-asset-buffer';
|
||||||
|
|
||||||
|
const CARD_OFFSET_TOP = 152;
|
||||||
|
const CARD_OFFSET_LEFT = 350;
|
||||||
|
const CARD_WIDTH = 500;
|
||||||
|
const CARD_HEIGHT = 250;
|
||||||
|
|
||||||
|
const size = {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
};
|
||||||
|
|
||||||
|
type SharePageOpenGraphImageProps = {
|
||||||
|
params: { slug: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params: { slug } }: SharePageOpenGraphImageProps) {
|
||||||
|
const [interSemiBold, interRegular, caveatRegular, shareFrameImage] = await Promise.all([
|
||||||
|
getAssetBuffer('/fonts/inter-semibold.ttf'),
|
||||||
|
getAssetBuffer('/fonts/inter-regular.ttf'),
|
||||||
|
getAssetBuffer('/fonts/caveat-regular.ttf'),
|
||||||
|
getAssetBuffer('/static/og-share-frame.png'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const recipientOrSender = await getRecipientOrSenderByShareLinkSlug({ slug }).catch(() => null);
|
||||||
|
|
||||||
|
if (!recipientOrSender) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecipient = 'Signature' in recipientOrSender;
|
||||||
|
|
||||||
|
const signatureImage = match(recipientOrSender)
|
||||||
|
.with({ Signature: P.array(P._) }, (recipient) => {
|
||||||
|
return recipient.Signature?.[0]?.signatureImageAsBase64 || null;
|
||||||
|
})
|
||||||
|
.otherwise((sender) => {
|
||||||
|
return sender.signature || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const signatureName = match(recipientOrSender)
|
||||||
|
.with({ Signature: P.array(P._) }, (recipient) => {
|
||||||
|
return recipient.name || recipient.email;
|
||||||
|
})
|
||||||
|
.otherwise((sender) => {
|
||||||
|
return sender.name || sender.email;
|
||||||
|
});
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div tw="relative flex h-full w-full">
|
||||||
|
{/* @ts-expect-error Lack of typing from ImageResponse */}
|
||||||
|
<img src={shareFrameImage} alt="og-share-frame" tw="absolute inset-0 w-full h-full" />
|
||||||
|
|
||||||
|
<div tw="absolute top-20 flex w-full items-center justify-center">
|
||||||
|
{/* @ts-expect-error Lack of typing from ImageResponse */}
|
||||||
|
<Logo tw="h-8 w-60" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{signatureImage ? (
|
||||||
|
<div
|
||||||
|
tw="absolute py-6 px-12 flex items-center justify-center text-center"
|
||||||
|
style={{
|
||||||
|
top: `${CARD_OFFSET_TOP}px`,
|
||||||
|
left: `${CARD_OFFSET_LEFT}px`,
|
||||||
|
width: `${CARD_WIDTH}px`,
|
||||||
|
height: `${CARD_HEIGHT}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={signatureImage} alt="signature" tw="opacity-60 h-full max-w-[100%]" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
tw="absolute py-6 px-12 -mt-2 flex items-center justify-center text-center text-slate-500"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Caveat',
|
||||||
|
fontSize: `${Math.max(
|
||||||
|
Math.min((CARD_WIDTH * 1.5) / signatureName.length, 80),
|
||||||
|
36,
|
||||||
|
)}px`,
|
||||||
|
top: `${CARD_OFFSET_TOP}px`,
|
||||||
|
left: `${CARD_OFFSET_LEFT}px`,
|
||||||
|
width: `${CARD_WIDTH}px`,
|
||||||
|
height: `${CARD_HEIGHT}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{signatureName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* <div
|
||||||
|
tw="absolute flex items-center justify-center text-slate-500"
|
||||||
|
style={{
|
||||||
|
top: `${CARD_OFFSET_TOP + CARD_HEIGHT - 45}px`,
|
||||||
|
left: `${CARD_OFFSET_LEFT}`,
|
||||||
|
width: `${CARD_WIDTH}px`,
|
||||||
|
fontSize: '30px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{signatureName}
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<div
|
||||||
|
tw="absolute flex flex-col items-center justify-center pt-12 w-full"
|
||||||
|
style={{
|
||||||
|
top: `${CARD_OFFSET_TOP + CARD_HEIGHT}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
tw="text-3xl text-slate-500"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRecipient
|
||||||
|
? 'I just signed with Documenso and you can too!'
|
||||||
|
: 'I just sent a document with Documenso and you can too!'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...size,
|
||||||
|
fonts: [
|
||||||
|
{
|
||||||
|
name: 'Caveat',
|
||||||
|
data: caveatRegular,
|
||||||
|
style: 'italic',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Inter',
|
||||||
|
data: interRegular,
|
||||||
|
style: 'normal',
|
||||||
|
weight: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Inter',
|
||||||
|
data: interSemiBold,
|
||||||
|
style: 'normal',
|
||||||
|
weight: 600,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
39
apps/web/src/app/(share)/share/[slug]/page.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { Metadata } from 'next';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { APP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
|
||||||
|
type SharePageProps = {
|
||||||
|
params: { slug: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateMetadata({ params: { slug } }: SharePageProps) {
|
||||||
|
return {
|
||||||
|
title: 'Documenso - Share',
|
||||||
|
description: 'I just signed a document with Documenso!',
|
||||||
|
openGraph: {
|
||||||
|
title: 'Documenso - Join the open source signing revolution',
|
||||||
|
description: 'I just signed with Documenso!',
|
||||||
|
type: 'website',
|
||||||
|
images: [`${APP_BASE_URL}/share/${slug}/opengraph`],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
site: '@documenso',
|
||||||
|
card: 'summary_large_image',
|
||||||
|
images: [`${APP_BASE_URL}/share/${slug}/opengraph`],
|
||||||
|
description: 'I just signed with Documenso!',
|
||||||
|
},
|
||||||
|
} satisfies Metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SharePage() {
|
||||||
|
const userAgent = headers().get('User-Agent') ?? '';
|
||||||
|
|
||||||
|
// https://stackoverflow.com/questions/47026171/how-to-detect-bots-for-open-graph-with-user-agent
|
||||||
|
if (/bot|facebookexternalhit|WhatsApp|google|bing|duckduckbot|MetaInspector/i.test(userAgent)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001');
|
||||||
|
}
|
||||||
@ -1,68 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import { Download } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
|
||||||
disabled?: boolean;
|
|
||||||
fileName?: string;
|
|
||||||
document?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DownloadButton = ({
|
|
||||||
className,
|
|
||||||
fileName,
|
|
||||||
document,
|
|
||||||
disabled,
|
|
||||||
...props
|
|
||||||
}: DownloadButtonProps) => {
|
|
||||||
/**
|
|
||||||
* Convert the document from base64 to a blob and download it.
|
|
||||||
*/
|
|
||||||
const onDownloadClick = () => {
|
|
||||||
if (!document) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let decodedDocument = document;
|
|
||||||
|
|
||||||
try {
|
|
||||||
decodedDocument = atob(document);
|
|
||||||
} catch (err) {
|
|
||||||
// We're just going to ignore this error and try to download the document
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
|
|
||||||
|
|
||||||
const blob = new Blob([documentBytes], {
|
|
||||||
type: 'application/pdf',
|
|
||||||
});
|
|
||||||
|
|
||||||
const link = window.document.createElement('a');
|
|
||||||
|
|
||||||
link.href = window.URL.createObjectURL(blob);
|
|
||||||
link.download = fileName || 'document.pdf';
|
|
||||||
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
window.URL.revokeObjectURL(link.href);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className={className}
|
|
||||||
disabled={disabled || !document}
|
|
||||||
onClick={onDownloadClick}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Download className="mr-2 h-5 w-5" />
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,17 +1,19 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import { CheckCircle2, Clock8, Share } from 'lucide-react';
|
import { CheckCircle2, Clock8 } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||||
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
|
|
||||||
import { DownloadButton } from './download-button';
|
import signingCelebration from '~/assets/signing-celebration.png';
|
||||||
import { SigningCard } from './signing-card';
|
|
||||||
|
import { ShareButton } from './share-button';
|
||||||
|
|
||||||
export type CompletedSigningPageProps = {
|
export type CompletedSigningPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@ -30,26 +32,32 @@ export default async function CompletedSigningPage({
|
|||||||
token,
|
token,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!document) {
|
if (!document || !document.documentData) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { documentData } = document;
|
||||||
|
|
||||||
const [fields, recipient] = await Promise.all([
|
const [fields, recipient] = await Promise.all([
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getRecipientByToken({ token }),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const recipientName =
|
const recipientName =
|
||||||
recipient.name ||
|
recipient.name ||
|
||||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||||
recipient.email;
|
recipient.email;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center pt-24">
|
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||||
{/* Card with recipient */}
|
{/* Card with recipient */}
|
||||||
<SigningCard name={recipientName} />
|
<SigningCard3D name={recipientName} signingCelebrationImage={signingCelebration} />
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="relative mt-6 flex w-full flex-col items-center">
|
||||||
{match(document.status)
|
{match(document.status)
|
||||||
.with(DocumentStatus.COMPLETED, () => (
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
<div className="text-documenso-700 flex items-center text-center">
|
<div className="text-documenso-700 flex items-center text-center">
|
||||||
@ -63,45 +71,44 @@ export default async function CompletedSigningPage({
|
|||||||
<span className="text-sm">Waiting for others to sign</span>
|
<span className="text-sm">Waiting for others to sign</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||||
|
You have signed "{document.title}"
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{match(document.status)
|
||||||
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
|
Everyone has signed! You will receive an Email copy of the signed document.
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
|
You will receive an Email copy of the signed document once everyone has signed.
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||||
|
<ShareButton documentId={document.id} token={recipient.token} />
|
||||||
|
|
||||||
|
<DocumentDownloadButton
|
||||||
|
className="flex-1"
|
||||||
|
fileName={document.title}
|
||||||
|
documentData={documentData}
|
||||||
|
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||||
|
Want to send slick signing links like this one?{' '}
|
||||||
|
<Link
|
||||||
|
href="https://documenso.com"
|
||||||
|
className="text-documenso-700 hover:text-documenso-600"
|
||||||
|
>
|
||||||
|
Check out Documenso.
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
|
||||||
You have signed "{document.title}"
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{match(document.status)
|
|
||||||
.with(DocumentStatus.COMPLETED, () => (
|
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
|
||||||
Everyone has signed! You will receive an Email copy of the signed document.
|
|
||||||
</p>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
|
||||||
You will receive an Email copy of the signed document once everyone has signed.
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
|
||||||
{/* TODO: Hook this up */}
|
|
||||||
<Button variant="outline" className="flex-1">
|
|
||||||
<Share className="mr-2 h-5 w-5" />
|
|
||||||
Share
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DownloadButton
|
|
||||||
className="flex-1"
|
|
||||||
fileName={document.title}
|
|
||||||
document={document.status === DocumentStatus.COMPLETED ? document.document : undefined}
|
|
||||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
|
||||||
Want so send slick signing links like this one?{' '}
|
|
||||||
<Link href="https://documenso.com" className="text-documenso-700 hover:text-documenso-600">
|
|
||||||
Check out Documenso.
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,146 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { HTMLAttributes, useState } from 'react';
|
||||||
|
|
||||||
|
import { Copy, Share, Twitter } from 'lucide-react';
|
||||||
|
|
||||||
|
import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
||||||
|
|
||||||
|
export type ShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [, copyToClipboard] = useCopyToClipboard();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: createOrGetShareLink,
|
||||||
|
data: shareLink,
|
||||||
|
isLoading,
|
||||||
|
} = trpc.shareLink.createOrGetShareLink.useMutation();
|
||||||
|
|
||||||
|
const onOpenChange = (nextOpen: boolean) => {
|
||||||
|
if (nextOpen) {
|
||||||
|
void createOrGetShareLink({
|
||||||
|
token,
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(nextOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCopyClick = async () => {
|
||||||
|
let { slug = '' } = shareLink || {};
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
const result = await createOrGetShareLink({
|
||||||
|
token,
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
slug = result.slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Copied to clipboard',
|
||||||
|
description: 'The sharing link has been copied to your clipboard.',
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTweetClick = async () => {
|
||||||
|
let { slug = '' } = shareLink || {};
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
const result = await createOrGetShareLink({
|
||||||
|
token,
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
slug = result.slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(
|
||||||
|
generateTwitterIntent(
|
||||||
|
`I just ${token ? 'signed' : 'sent'} a document with @documenso. Check it out!`,
|
||||||
|
`${window.location.origin}/share/${slug}`,
|
||||||
|
),
|
||||||
|
'_blank',
|
||||||
|
);
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!token || !documentId}
|
||||||
|
className="flex-1"
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{!isLoading && <Share className="mr-2 h-5 w-5" />}
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Share</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">Share your signing experience!</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col">
|
||||||
|
<div className="rounded-md border p-4">
|
||||||
|
I just {token ? 'signed' : 'sent'} a document with{' '}
|
||||||
|
<span className="font-medium text-blue-400">@documenso</span>
|
||||||
|
. Check it out!
|
||||||
|
<span className="mt-2 block" />
|
||||||
|
<span className="font-medium text-blue-400">
|
||||||
|
{window.location.origin}/share/{shareLink?.slug || '...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" className="mt-4" onClick={onTweetClick}>
|
||||||
|
<Twitter className="mr-2 h-4 w-4" />
|
||||||
|
Tweet
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative flex items-center justify-center gap-x-4 py-4 text-xs uppercase">
|
||||||
|
<div className="bg-border h-px flex-1" />
|
||||||
|
<span className="text-muted-foreground bg-transparent">Or</span>
|
||||||
|
<div className="bg-border h-px flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={onCopyClick}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Copy Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,67 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
|
|
||||||
import signingCelebration from '~/assets/signing-celebration.png';
|
|
||||||
|
|
||||||
export type SigningCardProps = {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SigningCard = ({ name }: SigningCardProps) => {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full max-w-xs md:max-w-sm">
|
|
||||||
<Card
|
|
||||||
className="group mx-auto flex aspect-[21/9] w-full items-center justify-center"
|
|
||||||
degrees={-145}
|
|
||||||
gradient
|
|
||||||
>
|
|
||||||
<CardContent
|
|
||||||
className="font-signature p-6 text-center"
|
|
||||||
style={{
|
|
||||||
container: 'main',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
|
|
||||||
style={{
|
|
||||||
fontSize: `max(min(4rem, ${(100 / name.length / 2).toFixed(4)}cqw), 1.875rem)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="absolute -inset-32 -z-10 flex items-center justify-center md:-inset-44 xl:-inset-60 2xl:-inset-80"
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
scale: 0.8,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
scale: 1,
|
|
||||||
opacity: 0.5,
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
delay: 0.5,
|
|
||||||
duration: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={signingCelebration}
|
|
||||||
alt="background pattern"
|
|
||||||
className="w-full"
|
|
||||||
style={{
|
|
||||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
|
||||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -77,7 +77,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
|
|||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
96
apps/web/src/app/(signing)/sign/[token]/email-field.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Recipient } from '@documenso/prisma/client';
|
||||||
|
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useRequiredSigningContext } from './provider';
|
||||||
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
|
export type EmailFieldProps = {
|
||||||
|
field: FieldWithSignature;
|
||||||
|
recipient: Recipient;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { email: providedEmail } = useRequiredSigningContext();
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
|
trpc.field.signFieldWithToken.useMutation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
|
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||||
|
|
||||||
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
|
const onSign = async () => {
|
||||||
|
try {
|
||||||
|
await signFieldWithToken({
|
||||||
|
token: recipient.token,
|
||||||
|
fieldId: field.id,
|
||||||
|
value: providedEmail ?? '',
|
||||||
|
isBase64: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while signing the document.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemove = async () => {
|
||||||
|
try {
|
||||||
|
await removeSignedFieldWithToken({
|
||||||
|
token: recipient.token,
|
||||||
|
fieldId: field.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while removing the signature.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!field.inserted && (
|
||||||
|
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Email</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
|
||||||
|
</SigningFieldContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,12 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||||
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import { Document, Field, Recipient } from '@documenso/prisma/client';
|
import { Document, Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@ -27,15 +30,22 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
|
|
||||||
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
||||||
|
|
||||||
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
} = useForm();
|
} = useForm();
|
||||||
|
|
||||||
const isComplete = fields.every((f) => f.inserted);
|
const uninsertedFields = useMemo(() => {
|
||||||
|
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
const onFormSubmit = async () => {
|
||||||
if (!isComplete) {
|
setValidateUninsertedFields(true);
|
||||||
|
const isFieldsValid = validateFieldsInserted(fields);
|
||||||
|
|
||||||
|
if (!isFieldsValid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,7 +64,16 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
)}
|
)}
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<div className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}>
|
{validateUninsertedFields && uninsertedFields[0] && (
|
||||||
|
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||||
|
Click to insert field
|
||||||
|
</FieldToolTip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
||||||
|
>
|
||||||
<div className={cn('flex flex-1 flex-col')}>
|
<div className={cn('flex flex-1 flex-col')}>
|
||||||
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
||||||
|
|
||||||
@ -101,23 +120,18 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
onClick={() => router.back()}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button className="w-full" type="submit" size="lg" loading={isSubmitting}>
|
||||||
className="w-full"
|
|
||||||
type="submit"
|
|
||||||
size="lg"
|
|
||||||
disabled={!isComplete || isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
|
||||||
Complete
|
Complete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||