Compare commits
181 Commits
feat/runti
...
feat/teams
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39869c46a3 | ||
|
|
d546907c53 | ||
|
|
917a1271bf | ||
|
|
ca703fc221 | ||
|
|
f4309c1c83 | ||
|
|
9d626473c8 | ||
|
|
f7cf33c61b | ||
|
|
8f4fea2f14 | ||
|
|
5a32b5cafd | ||
|
|
e9312ada51 | ||
|
|
1c52c7ebcd | ||
|
|
495cd35f7c | ||
|
|
5a5d00fb2e | ||
|
|
1aa0fc3101 | ||
|
|
48cdf43dcb | ||
|
|
1d9593dd0f | ||
|
|
9ad94f9862 | ||
|
|
972c20f906 | ||
|
|
519c645d06 | ||
|
|
7babd82470 | ||
|
|
298396c86c | ||
|
|
268a5c6508 | ||
|
|
c40c9b20ec | ||
|
|
84a0c39810 | ||
|
|
1af909835d | ||
|
|
01caa949d9 | ||
|
|
075fdd1f88 | ||
|
|
006a559026 | ||
|
|
ff64671e49 | ||
|
|
089ba1c30e | ||
|
|
cbcd893cfd | ||
|
|
83dfe92d7a | ||
|
|
e43e8f8c4a | ||
|
|
82da337a56 | ||
|
|
6e10947d00 | ||
|
|
682cb37786 | ||
|
|
5809480f02 | ||
|
|
1eeb5fb103 | ||
|
|
88534fa1c6 | ||
|
|
31a9127c9e | ||
|
|
6d34ebd91b | ||
|
|
f2d4c0721d | ||
|
|
f9139a54a5 | ||
|
|
2d931b2c9b | ||
|
|
5c1d30bfbb | ||
|
|
95041fa2e4 | ||
|
|
49736d2587 | ||
|
|
ee5ce78c82 | ||
|
|
3b3987dcf8 | ||
|
|
78a1ee2af0 | ||
|
|
dbdef79263 | ||
|
|
323380d757 | ||
|
|
e4b7747f66 | ||
|
|
0697e7f817 | ||
|
|
e1d3874e79 | ||
|
|
497d9140d2 | ||
|
|
7d22957404 | ||
|
|
38e5b1d3ce | ||
|
|
09dcc2cac0 | ||
|
|
d8d36ae8e2 | ||
|
|
dfec8df31e | ||
|
|
bc38009392 | ||
|
|
5e9bc56329 | ||
|
|
48f6765e76 | ||
|
|
7feba02e08 | ||
|
|
4e799e68ef | ||
|
|
1831084970 | ||
|
|
935601ad16 | ||
|
|
7b4e38a032 | ||
|
|
2c5d547cdf | ||
|
|
c313da5028 | ||
|
|
5b98bac53b | ||
|
|
d7e44fc068 | ||
|
|
e03db5c6b3 | ||
|
|
d58433c8a0 | ||
|
|
419f27536b | ||
|
|
9a7e5d333d | ||
|
|
39b97a97fe | ||
|
|
328b2e7604 | ||
|
|
43b1a89c76 | ||
|
|
cd6184406d | ||
|
|
1a34f9fa7a | ||
|
|
3ff7b188d7 | ||
|
|
684e5272d2 | ||
|
|
b39a42ecd2 | ||
|
|
e81183f324 | ||
|
|
bfc630aa6a | ||
|
|
2068d980ff | ||
|
|
0baa2696b4 | ||
|
|
741201822a | ||
|
|
520522bef7 | ||
|
|
8ab1b0cf6b | ||
|
|
bfedabdc10 | ||
|
|
52e696c90e | ||
|
|
02d91d9cd4 | ||
|
|
ac529a89fc | ||
|
|
f181099e74 | ||
|
|
02e96bbd0a | ||
|
|
36e48e67ee | ||
|
|
d8588b780a | ||
|
|
ef84f5ba98 | ||
|
|
68120794f8 | ||
|
|
88dc797423 | ||
|
|
a43be0432b | ||
|
|
0f11cc0b4b | ||
|
|
f310139a13 | ||
|
|
859b789018 | ||
|
|
340c929806 | ||
|
|
43b1a14415 | ||
|
|
40a4ec4436 | ||
|
|
eccf63dcfd | ||
|
|
a98b429052 | ||
|
|
c46a69f865 | ||
|
|
369d08ae6e | ||
|
|
a906833657 | ||
|
|
4733f1e84b | ||
|
|
ab0d38eaf4 | ||
|
|
b903de983b | ||
|
|
6b519a67c2 | ||
|
|
39d18e93c5 | ||
|
|
7dac5072f7 | ||
|
|
fbfaca190b | ||
|
|
486b1cbf62 | ||
|
|
16fb90f4d2 | ||
|
|
53cb38a394 | ||
|
|
073a050587 | ||
|
|
39c01f4e8d | ||
|
|
335684d0b7 | ||
|
|
792158c2cb | ||
|
|
83153cee32 | ||
|
|
2d2bdc536e | ||
|
|
c16c36a1fc | ||
|
|
6bbeaa084c | ||
|
|
231a307b89 | ||
|
|
1d79ebbda3 | ||
|
|
252dd0008c | ||
|
|
35d0fed8b3 | ||
|
|
0b2dce2238 | ||
|
|
1e29dfd823 | ||
|
|
dc56c2abf2 | ||
|
|
62809e9506 | ||
|
|
318dfcafc3 | ||
|
|
4ff8592e8f | ||
|
|
dad56b4929 | ||
|
|
7e4c44e820 | ||
|
|
adc97802ea | ||
|
|
0e40658201 | ||
|
|
d347359d2f | ||
|
|
fdf5b3908d | ||
|
|
8048c29480 | ||
|
|
84b958d5b7 | ||
|
|
d8688692f7 | ||
|
|
8230349114 | ||
|
|
c054fc78a4 | ||
|
|
5de0c464f0 | ||
|
|
9444e0cc67 | ||
|
|
be0fe079a3 | ||
|
|
fbbc3b89c3 | ||
|
|
6c73453542 | ||
|
|
17eeaa2d25 | ||
|
|
a8d49bb8b8 | ||
|
|
e077c36fe4 | ||
|
|
7ce4cf8381 | ||
|
|
cebdf5fd8e | ||
|
|
8adc44802f | ||
|
|
06714a2aeb | ||
|
|
1c9cec1e93 | ||
|
|
b4f1a5abce | ||
|
|
e838a07bf9 | ||
|
|
8722e4de74 | ||
|
|
f7d8ebb9de | ||
|
|
67f3b2de45 | ||
|
|
0da080aa41 | ||
|
|
fe25239a4e | ||
|
|
5ea4a16e36 | ||
|
|
5002a475d1 | ||
|
|
ca9c0d7bf0 | ||
|
|
3f0341c7d4 | ||
|
|
c3fe98b05f | ||
|
|
608a4eaaa6 | ||
|
|
d6ae0b44e6 |
21
.env.example
21
.env.example
@@ -2,6 +2,11 @@
|
|||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
NEXTAUTH_SECRET="secret"
|
NEXTAUTH_SECRET="secret"
|
||||||
|
|
||||||
|
# [[CRYPTO]]
|
||||||
|
# Application Key for symmetric encryption and decryption
|
||||||
|
# This should be a random string of at least 32 characters
|
||||||
|
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
||||||
|
|
||||||
# [[AUTH OPTIONAL]]
|
# [[AUTH OPTIONAL]]
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||||
@@ -24,15 +29,15 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
|
|||||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||||
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
||||||
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
|
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
|
||||||
NEXT_PRIVATE_UPLOAD_ENDPOINT=
|
NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
|
||||||
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
|
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
|
||||||
NEXT_PRIVATE_UPLOAD_REGION=
|
NEXT_PRIVATE_UPLOAD_REGION="unknown"
|
||||||
# REQUIRED: Defines the bucket to use for the S3 storage transport.
|
# REQUIRED: Defines the bucket to use for the S3 storage transport.
|
||||||
NEXT_PRIVATE_UPLOAD_BUCKET=
|
NEXT_PRIVATE_UPLOAD_BUCKET="documenso"
|
||||||
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
|
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
|
||||||
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=
|
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID="documenso"
|
||||||
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
|
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
|
||||||
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
|
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password"
|
||||||
|
|
||||||
# [[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
|
||||||
@@ -73,15 +78,15 @@ NEXT_PRIVATE_STRIPE_API_KEY=
|
|||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
||||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID=
|
NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID=
|
||||||
|
|
||||||
# [[FEATURES]]
|
# [[FEATURES]]
|
||||||
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
||||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||||
# OPTIONAL: Defines the host to use for PostHog.
|
|
||||||
NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com"
|
|
||||||
# OPTIONAL: Leave blank to disable billing.
|
# OPTIONAL: Leave blank to disable billing.
|
||||||
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||||
|
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||||
|
|
||||||
# This is only required for the marketing site
|
# This is only required for the marketing site
|
||||||
# [[REDIS]]
|
# [[REDIS]]
|
||||||
|
|||||||
7
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
7
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,11 +1,10 @@
|
|||||||
name: "Bug Report"
|
name: 'Bug Report'
|
||||||
labels: ["bug"]
|
labels: ['bug']
|
||||||
description: Create a bug report to help us improve
|
description: Create a bug report to help us improve
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value:
|
value: Thank you for reporting an issue.
|
||||||
Thank you for reporting an issue.
|
|
||||||
Please fill in as much of the form below as you're able to.
|
Please fill in as much of the form below as you're able to.
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: "Feature Request"
|
name: 'Feature Request'
|
||||||
description: Suggest a new idea or enhancement for this project
|
description: Suggest a new idea or enhancement for this project
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/improvement.yml
vendored
2
.github/ISSUE_TEMPLATE/improvement.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: "General Improvement"
|
name: 'General Improvement'
|
||||||
description: Suggest a minor enhancement or improvement for this project
|
description: Suggest a minor enhancement or improvement for this project
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|||||||
32
.github/dependabot.yml
vendored
32
.github/dependabot.yml
vendored
@@ -4,29 +4,29 @@ updates:
|
|||||||
- package-ecosystem: 'github-actions'
|
- package-ecosystem: 'github-actions'
|
||||||
directory: '/'
|
directory: '/'
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: 'weekly'
|
||||||
target-branch: "main"
|
target-branch: 'main'
|
||||||
labels:
|
labels:
|
||||||
- "ci dependencies"
|
- 'ci dependencies'
|
||||||
- "ci"
|
- 'ci'
|
||||||
open-pull-requests-limit: 0
|
open-pull-requests-limit: 0
|
||||||
|
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: 'npm'
|
||||||
directory: "/apps/marketing"
|
directory: '/apps/marketing'
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: 'weekly'
|
||||||
target-branch: "main"
|
target-branch: 'main'
|
||||||
labels:
|
labels:
|
||||||
- "npm dependencies"
|
- 'npm dependencies'
|
||||||
- "frontend"
|
- 'frontend'
|
||||||
open-pull-requests-limit: 0
|
open-pull-requests-limit: 0
|
||||||
|
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: 'npm'
|
||||||
directory: "/apps/web"
|
directory: '/apps/web'
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: 'weekly'
|
||||||
target-branch: "main"
|
target-branch: 'main'
|
||||||
labels:
|
labels:
|
||||||
- "npm dependencies"
|
- 'npm dependencies'
|
||||||
- "frontend"
|
- 'frontend'
|
||||||
open-pull-requests-limit: 0
|
open-pull-requests-limit: 0
|
||||||
|
|||||||
21
.github/labeler.yml
vendored
Normal file
21
.github/labeler.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
'apps: marketing':
|
||||||
|
- apps/marketing/**
|
||||||
|
|
||||||
|
'apps: web':
|
||||||
|
- apps/web/**
|
||||||
|
|
||||||
|
'version bump 👀':
|
||||||
|
- '**/package.json'
|
||||||
|
- '**/package-lock.json'
|
||||||
|
|
||||||
|
'🚨 migrations 🚨':
|
||||||
|
- packages/prisma/migrations/**/migration.sql
|
||||||
|
|
||||||
|
'🚨 e2e changes 🚨':
|
||||||
|
- packages/app-tests/e2e/**
|
||||||
|
|
||||||
|
'🚨 .env changes 🚨':
|
||||||
|
- .env.example
|
||||||
|
|
||||||
|
'pkg: ee changes':
|
||||||
|
- packages/ee/**
|
||||||
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -1,10 +1,10 @@
|
|||||||
name: "Continuous Integration"
|
name: 'Continuous Integration'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: ['main']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: ['main']
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||||
@@ -14,17 +14,17 @@ env:
|
|||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build_app:
|
||||||
name: Build
|
name: Build App
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
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@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -37,3 +37,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
build_docker:
|
||||||
|
name: Build Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Build Docker Image
|
||||||
|
run: ./docker/build.sh
|
||||||
|
|||||||
44
.github/workflows/codeql-analysis.yml
vendored
44
.github/workflows/codeql-analysis.yml
vendored
@@ -1,11 +1,11 @@
|
|||||||
name: "CodeQL"
|
name: 'CodeQL'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: ['main']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: ['main']
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
@@ -19,30 +19,30 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
language: [ 'javascript' ]
|
language: ['javascript']
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
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
|
- name: Copy env
|
||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|
||||||
- name: Build Documenso
|
- name: Build Documenso
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v2
|
||||||
with:
|
with:
|
||||||
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
|
||||||
|
|||||||
24
.github/workflows/deploy.yml
vendored
Normal file
24
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: Deploy to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GH_TOKEN }}
|
||||||
|
|
||||||
|
- name: Push to release branch
|
||||||
|
run: |
|
||||||
|
git checkout release || git checkout -b release
|
||||||
|
git merge --ff-only main
|
||||||
|
git push origin release
|
||||||
41
.github/workflows/e2e-tests.yml
vendored
41
.github/workflows/e2e-tests.yml
vendored
@@ -1,51 +1,50 @@
|
|||||||
name: Playwright Tests
|
name: Playwright Tests
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: ['main']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: ['main']
|
||||||
jobs:
|
jobs:
|
||||||
e2e_tests:
|
e2e_tests:
|
||||||
|
name: "E2E Tests"
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
|
cache: npm
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Copy env
|
- name: Copy env
|
||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Start Services
|
||||||
|
run: npm run dx:up
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
- name: Generate Prisma Client
|
- name: Generate Prisma Client
|
||||||
run: npm run prisma:generate -w @documenso/prisma
|
run: npm run prisma:generate -w @documenso/prisma
|
||||||
|
|
||||||
- name: Create the database
|
- name: Create the database
|
||||||
run: npm run prisma:migrate-dev
|
run: npm run prisma:migrate-dev
|
||||||
|
|
||||||
|
- name: Seed the database
|
||||||
|
run: npm run prisma:seed
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: npm run ci
|
run: npm run ci
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: test-results
|
||||||
path: playwright-report/
|
path: "packages/app-tests/**/test-results/*"
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
env:
|
env:
|
||||||
NEXT_PRIVATE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
|
|
||||||
NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
|
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|||||||
29
.github/workflows/first-interaction.yml
vendored
Normal file
29
.github/workflows/first-interaction.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: 'Welcome New Contributors'
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: ['opened']
|
||||||
|
issues:
|
||||||
|
types: ['opened']
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
welcome-message:
|
||||||
|
name: Welcome Contributors
|
||||||
|
if: github.event.action == 'opened'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/first-interaction@v1
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
pr-message: |
|
||||||
|
Thank you for creating your first Pull Request and for being a part of the open signing revolution! 💚🚀
|
||||||
|
<br /> Feel free to hop into our community in [Discord](https://documen.so/discord)
|
||||||
|
issue-message: |
|
||||||
|
Thank you for opening your first issue and for being a part of the open signing revolution!
|
||||||
|
<br /> One of our team members will review it and get back to you as soon as it possible 💚
|
||||||
|
<br /> Meanwhile, please feel free to hop into our community in [Discord](https://documen.so/discord)
|
||||||
63
.github/workflows/issue-assignee-check.yml
vendored
Normal file
63
.github/workflows/issue-assignee-check.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
name: 'Issue Assignee Check'
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: ['assigned']
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
countIssues:
|
||||||
|
if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install Octokit
|
||||||
|
run: npm install @octokit/rest@18
|
||||||
|
|
||||||
|
- name: Check Assigned User's Issue Count
|
||||||
|
id: parse-comment
|
||||||
|
uses: actions/github-script@v5
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const { Octokit } = require("@octokit/rest");
|
||||||
|
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||||
|
|
||||||
|
const username = context.payload.issue.assignee.login;
|
||||||
|
console.log(`Username Extracted: ${username}`);
|
||||||
|
|
||||||
|
const { data: issues } = await octokit.issues.listForRepo({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
assignee: username,
|
||||||
|
state: 'open'
|
||||||
|
});
|
||||||
|
|
||||||
|
const issueCount = issues.length;
|
||||||
|
console.log(`Issue Count For ${username}: ${issueCount}`);
|
||||||
|
|
||||||
|
if (issueCount > 3) {
|
||||||
|
let issueCountMessage = `### 🚨 Documenso Police 🚨`;
|
||||||
|
issueCountMessage += `\n@${username} has ${issueCount} open issues assigned already. Consider whether this issue should be assigned to them or left open for another contributor.`;
|
||||||
|
|
||||||
|
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: issueCountMessage,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
21
.github/workflows/issue-opened.yml
vendored
Normal file
21
.github/workflows/issue-opened.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: 'Label Issues'
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: ['opened', 'reopened']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label_issues:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v6
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
github.rest.issues.addLabels({
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
labels: ["needs triage"]
|
||||||
|
})
|
||||||
20
.github/workflows/pr-labeler.yml
vendored
Normal file
20
.github/workflows/pr-labeler.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
name: 'PR Labeler'
|
||||||
|
|
||||||
|
on:
|
||||||
|
- pull_request_target
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
labeler:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/labeler@v4
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
sync-labels: ''
|
||||||
64
.github/workflows/pr-review-reminder.yml
vendored
Normal file
64
.github/workflows/pr-review-reminder.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
name: 'PR Review Reminder'
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: ['opened', 'reopened', 'ready_for_review', 'review_requested']
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checkPRs:
|
||||||
|
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install Octokit
|
||||||
|
run: npm install @octokit/rest@18
|
||||||
|
|
||||||
|
- name: Check user's PRs awaiting review
|
||||||
|
id: parse-prs
|
||||||
|
uses: actions/github-script@v5
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const { Octokit } = require("@octokit/rest");
|
||||||
|
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||||
|
|
||||||
|
const username = context.payload.pull_request.user.login;
|
||||||
|
console.log(`Username Extracted: ${username}`);
|
||||||
|
|
||||||
|
const { data: pullRequests } = await octokit.pulls.list({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
state: 'open',
|
||||||
|
sort: 'created',
|
||||||
|
direction: 'asc',
|
||||||
|
});
|
||||||
|
|
||||||
|
const userPullRequests = pullRequests.filter(pr => pr.user.login === username && (pr.state === 'open' || pr.state === 'ready_for_review'));
|
||||||
|
const prCount = userPullRequests.length;
|
||||||
|
console.log(`PR Count for ${username}: ${prCount}`);
|
||||||
|
|
||||||
|
if (prCount > 3) {
|
||||||
|
let prReminderMessage = `🚨 @${username} has ${prCount} pull requests awaiting review. Please consider reviewing them when possible. 🚨`;
|
||||||
|
|
||||||
|
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.pull_request.number,
|
||||||
|
body: prReminderMessage,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
45
.github/workflows/semantic-pull-requests.yml
vendored
45
.github/workflows/semantic-pull-requests.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: "Validate PR Name"
|
name: 'Validate PR Name'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
@@ -9,13 +9,54 @@ on:
|
|||||||
- synchronize
|
- synchronize
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: read
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate-pr:
|
validate-pr:
|
||||||
name: Validate PR title
|
name: Validate PR title
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Check PR creator's previous activity
|
||||||
|
id: check_activity
|
||||||
|
run: |
|
||||||
|
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
|
||||||
|
ACTIVITY=$(curl -s "https://api.github.com/search/commits?q=author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
|
||||||
|
if [ "$ACTIVITY" -eq 0 ]; then
|
||||||
|
echo "::set-output name=is_new::true"
|
||||||
|
else
|
||||||
|
echo "::set-output name=is_new::false"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Count PRs created by user
|
||||||
|
id: count_prs
|
||||||
|
run: |
|
||||||
|
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
|
||||||
|
PR_COUNT=$(curl -s "https://api.github.com/search/issues?q=type:pr+is:open+author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
|
||||||
|
echo "::set-output name=pr_count::$PR_COUNT"
|
||||||
|
|
||||||
- uses: amannn/action-semantic-pull-request@v5
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
|
id: lint_pr_title
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
message: |
|
||||||
|
Hey There! and thank you for opening this pull request! 📝👋🏼
|
||||||
|
|
||||||
|
We require pull request titles to follow the [Conventional Commits Spec](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
||||||
|
|
||||||
|
Details:
|
||||||
|
|
||||||
|
```
|
||||||
|
${{ steps.lint_pr_title.outputs.error_message }}
|
||||||
|
```
|
||||||
|
|
||||||
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null && steps.check_activity.outputs.is_new == 'false' && steps.count_prs.outputs.pr_count < 2}}
|
||||||
|
uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
message: |
|
||||||
|
Thank you for following the naming conventions for pull request titles! 💚🚀
|
||||||
|
|||||||
25
.github/workflows/stale.yml
vendored
Normal file
25
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: 'Mark Stale Issues and PRs'
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 */8 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v4
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
days-before-pr-stale: 30
|
||||||
|
days-before-issue-stale: 30
|
||||||
|
stale-issue-message: 'This issue has not seen activity for a while. It will be closed in 30 days unless further activity is detected'
|
||||||
|
stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.'
|
||||||
|
close-issue-message: 'This issue has been closed because of inactivity.'
|
||||||
|
close-pr-message: 'This PR has been closed because of inactivity.'
|
||||||
|
exempt-pr-labels: 'WIP,on-hold,needs review'
|
||||||
|
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned'
|
||||||
16
.prettierignore
Normal file
16
.prettierignore
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
public
|
||||||
|
**/**/node_modules
|
||||||
|
**/**/.next
|
||||||
|
**/**/public
|
||||||
|
|
||||||
|
*.lock
|
||||||
|
*.log
|
||||||
|
*.test.ts
|
||||||
|
|
||||||
|
.gitignore
|
||||||
|
.npmignore
|
||||||
|
.prettierignore
|
||||||
|
.DS_Store
|
||||||
|
.eslintignore
|
||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -1,10 +1,13 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.eslint": true
|
"source.fixAll.eslint": "explicit"
|
||||||
},
|
},
|
||||||
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
||||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||||
"javascript.preferences.useAliasesForRenames": false,
|
"javascript.preferences.useAliasesForRenames": false,
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
|
"files.eol": "\n",
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"editor.insertSpaces": true
|
||||||
}
|
}
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -139,11 +139,13 @@ npm run d
|
|||||||
|
|
||||||
1. **App** - http://localhost:3000
|
1. **App** - http://localhost:3000
|
||||||
2. **Incoming Mail Access** - http://localhost:9000
|
2. **Incoming Mail Access** - http://localhost:9000
|
||||||
|
|
||||||
3. **Database Connection Details**
|
3. **Database Connection Details**
|
||||||
|
|
||||||
- **Port**: 54320
|
- **Port**: 54320
|
||||||
- **Connection**: Use your favorite database client to connect using the provided port.
|
- **Connection**: Use your favorite database client to connect using the provided port.
|
||||||
|
|
||||||
|
4. **S3 Storage Dashboard** - http://localhost:9001
|
||||||
|
|
||||||
## Developer Setup
|
## Developer Setup
|
||||||
|
|
||||||
### Manual Setup
|
### Manual Setup
|
||||||
@@ -193,6 +195,12 @@ git clone https://github.com/documenso/documenso
|
|||||||
|
|
||||||
We support DevContainers for VSCode. [Click here to get started.](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/documenso/documenso)
|
We support DevContainers for VSCode. [Click here to get started.](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/documenso/documenso)
|
||||||
|
|
||||||
|
### Video walkthrough
|
||||||
|
|
||||||
|
If you're a visual learner and prefer to watch a video walkthrough of setting up Documenso locally, check out this video:
|
||||||
|
|
||||||
|
[](https://youtu.be/Y0ppIQrEnZs)
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
🚧 Docker containers and images are current in progress. We are actively working on bringing a simple Docker build and publish pipeline for Documenso.
|
🚧 Docker containers and images are current in progress. We are actively working on bringing a simple Docker build and publish pipeline for Documenso.
|
||||||
@@ -234,7 +242,7 @@ Now you can install the dependencies and build it:
|
|||||||
|
|
||||||
```
|
```
|
||||||
npm i
|
npm i
|
||||||
npm run:build:web
|
npm run build:web
|
||||||
npm run prisma:migrate-deploy
|
npm run prisma:migrate-deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ tags:
|
|||||||
Today I'm happy to announce that we closed a \$1.25M Pre-Seed round for Documenso, bringing our total funding to \$1.54M. The round actually closed last month, we just were sneaky about it.
|
Today I'm happy to announce that we closed a \$1.25M Pre-Seed round for Documenso, bringing our total funding to \$1.54M. The round actually closed last month, we just were sneaky about it.
|
||||||
|
|
||||||
## Two more for the road (to open signing)
|
## Two more for the road (to open signing)
|
||||||
|
|
||||||
We're ecstatic to welcome [OSS Capital](https://twitter.com/osscapital) and especially [Joseph Jacks](https://twitter.com/JosephJacks_) to the inner circle of the open signing revolution. We're also fortunate to be joined by Orrick's very own [John Harrison](https://www.linkedin.com/in/john-harrison-a1213b9/) and his legal experience. For those who are wondering, yes, the round was, of course, signed using Documenso.
|
We're ecstatic to welcome [OSS Capital](https://twitter.com/osscapital) and especially [Joseph Jacks](https://twitter.com/JosephJacks_) to the inner circle of the open signing revolution. We're also fortunate to be joined by Orrick's very own [John Harrison](https://www.linkedin.com/in/john-harrison-a1213b9/) and his legal experience. For those who are wondering, yes, the round was, of course, signed using Documenso.
|
||||||
|
|
||||||
## Open Source, Open Metrics
|
## Open Source, Open Metrics
|
||||||
|
|
||||||
If you follow us, you know we're firmly committed to the open source values of openness and transparency. For us, this includes not only the code side of things but also the business. As we aim to build trust among our investors, customers, and partners, we want to be open about what's going on. We also want to allow everyone to learn from our data and choices, just as we did from so many other COSS (Commercial Open Source) startups. The term "Open Startup" isn't precisely defined (and probably will never be, just like startup). There is however a [great write-up](https://cal.com/blog/open-startup) about the basics by the founder of our favorite open source scheduling tool Cal.com.
|
If you follow us, you know we're firmly committed to the open source values of openness and transparency. For us, this includes not only the code side of things but also the business. As we aim to build trust among our investors, customers, and partners, we want to be open about what's going on. We also want to allow everyone to learn from our data and choices, just as we did from so many other COSS (Commercial Open Source) startups. The term "Open Startup" isn't precisely defined (and probably will never be, just like startup). There is however a [great write-up](https://cal.com/blog/open-startup) about the basics by the founder of our favorite open source scheduling tool Cal.com.
|
||||||
|
|
||||||
The two main takeaways are:
|
The two main takeaways are:
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ We’re an open-source project and focus on building a great developer experienc
|
|||||||
|
|
||||||
So, we’re switching all conversations, team and community-wide, to Discord.
|
So, we’re switching all conversations, team and community-wide, to Discord.
|
||||||
|
|
||||||
In this post, we won’t debate *why* we’re switching — Slack vs. Discord is a long-lasting debate with pros and cons, and fans on both sides. There are great [stories](https://blog.meilisearch.com/from-slack-to-discord-our-migration/) and [threads](https://twitter.com/McPizza0/status/1655519558600470528) on the topic. We just don’t want to write yet another story here.
|
In this post, we won’t debate _why_ we’re switching — Slack vs. Discord is a long-lasting debate with pros and cons, and fans on both sides. There are great [stories](https://blog.meilisearch.com/from-slack-to-discord-our-migration/) and [threads](https://twitter.com/McPizza0/status/1655519558600470528) on the topic. We just don’t want to write yet another story here.
|
||||||
|
|
||||||
Instead, we’ll focus on *how* we plan to make the switch.
|
Instead, we’ll focus on _how_ we plan to make the switch.
|
||||||
|
|
||||||
## Who is this story for?
|
## Who is this story for?
|
||||||
|
|
||||||
@@ -46,90 +46,91 @@ The detailed plan goes like this:
|
|||||||
- 2023-07-26 `t+1`: The team switches to Discord. The objective is to get used to the product and to customize it to feel at home and, when we’re ready to welcome the community, to make new members feel at home, too.
|
- 2023-07-26 `t+1`: The team switches to Discord. The objective is to get used to the product and to customize it to feel at home and, when we’re ready to welcome the community, to make new members feel at home, too.
|
||||||
- 2023-08-02 `t+8`: We announce to the community the upcoming changes in the different channels — GitHub, Twitter, and Slack.
|
- 2023-08-02 `t+8`: We announce to the community the upcoming changes in the different channels — GitHub, Twitter, and Slack.
|
||||||
|
|
||||||
- **GitHub**
|
- **GitHub**
|
||||||
- Create new Pull Request
|
|
||||||
- Add story to the blog
|
|
||||||
- Update link to the community
|
|
||||||
|
|
||||||
```
|
- Create new Pull Request
|
||||||
https://documen.so/discord
|
- Add story to the blog
|
||||||
```
|
- Update link to the community
|
||||||
|
```
|
||||||
|
https://documen.so/discord
|
||||||
|
```
|
||||||
|
- Start a new Discussion
|
||||||
|
|
||||||
- Start a new Discussion
|
```markdown
|
||||||
|
Happy Wednesday!
|
||||||
|
|
||||||
```markdown
|
TL,DR: We’re switching to Discord. [Join the fun!](https://documen.so/discord)
|
||||||
Happy Wednesday!
|
|
||||||
|
|
||||||
TL,DR: We’re switching to Discord. [Join the fun!](https://documen.so/discord)
|
We want to build a beautiful, open-source DocuSign alternative. As we're growing (reached 2.3K Stars), we feel the need to have a more community- and developer-friendly environment to share ideas, support, and memes.
|
||||||
|
|
||||||
We want to build a beautiful, open-source DocuSign alternative. As we're growing (reached 2.3K Stars), we feel the need to have a more community- and developer-friendly environment to share ideas, support, and memes.
|
Make sure to join the server to keep up to date on all things Documenso.
|
||||||
|
|
||||||
Make sure to join the server to keep up to date on all things Documenso.
|
Oh and, spoiler alert, there may be some swag there 👀
|
||||||
|
|
||||||
Oh and, spoiler alert, there may be some swag there 👀
|
See you there!
|
||||||
|
Flo
|
||||||
|
```
|
||||||
|
|
||||||
See you there!
|
- **Twitter**
|
||||||
Flo
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Twitter**
|
- [Tweet the announcement](https://twitter.com/documenso/status/1686719482096766977)
|
||||||
- [Tweet the announcement](https://twitter.com/documenso/status/1686719482096766977)
|
- Pin Tweet
|
||||||
- Pin Tweet
|
- Update link in bio
|
||||||
- Update link in bio
|
|
||||||
|
|
||||||
```
|
```
|
||||||
The Open Source DocuSign Alternative.
|
The Open Source DocuSign Alternative.
|
||||||
|
|
||||||
http://documen.so/github
|
http://documen.so/github
|
||||||
http://documen.so/discord
|
http://documen.so/discord
|
||||||
http://documen.so/manifest
|
http://documen.so/manifest
|
||||||
ㅤ
|
ㅤ
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Slack**
|
- **Slack**
|
||||||
- Post message in `#general`
|
|
||||||
|
|
||||||
```markdown
|
- Post message in `#general`
|
||||||
Happy Wednesday!
|
|
||||||
|
|
||||||
TL,DR: We’re switching to Discord. [Join the fun!](https://documen.so/discord)
|
```markdown
|
||||||
|
Happy Wednesday!
|
||||||
|
|
||||||
We want to build a beautiful, open-source DocuSign alternative. As we're growing (reached 2.3K Stars), we feel the need to have a more community- and developer-friendly environment to share ideas, support, and memes.
|
TL,DR: We’re switching to Discord. [Join the fun!](https://documen.so/discord)
|
||||||
|
|
||||||
Make sure to [join the server](https://documen.so/discord) to keep up to date on all things Documenso.
|
We want to build a beautiful, open-source DocuSign alternative. As we're growing (reached 2.3K Stars), we feel the need to have a more community- and developer-friendly environment to share ideas, support, and memes.
|
||||||
|
|
||||||
Oh and, spoiler alert, there may be some swag there 👀
|
Make sure to [join the server](https://documen.so/discord) to keep up to date on all things Documenso.
|
||||||
|
|
||||||
See you there!
|
Oh and, spoiler alert, there may be some swag there 👀
|
||||||
Flo
|
|
||||||
```
|
|
||||||
|
|
||||||
- Pin post
|
See you there!
|
||||||
- Set topic and description
|
Flo
|
||||||
|
```
|
||||||
|
|
||||||
```
|
- Pin post
|
||||||
We're switching to Discord. Join the fun: https://documen.so/discord
|
- Set topic and description
|
||||||
```
|
```
|
||||||
|
We're switching to Discord. Join the fun: https://documen.so/discord
|
||||||
- Archive channels: `#code-review` `#how-to` `#meet-and-greet` `#random-memes` `#self-hosting` `#support`
|
```
|
||||||
|
- Archive channels: `#code-review` `#how-to` `#meet-and-greet` `#random-memes` `#self-hosting` `#support`
|
||||||
|
|
||||||
- 2023-08-09 `t+15`: 7 days later, we send a reminder on Slack.
|
- 2023-08-09 `t+15`: 7 days later, we send a reminder on Slack.
|
||||||
- **Slack**
|
|
||||||
- Schedule reminder in `#general`
|
|
||||||
|
|
||||||
```
|
- **Slack**
|
||||||
Friendly reminder: we're switching to Discord and will soon disconnect this Slack workspace.
|
|
||||||
|
|
||||||
Join the fun! https://documen.so/discord
|
- Schedule reminder in `#general`
|
||||||
```
|
|
||||||
|
```
|
||||||
|
Friendly reminder: we're switching to Discord and will soon disconnect this Slack workspace.
|
||||||
|
|
||||||
|
Join the fun! https://documen.so/discord
|
||||||
|
```
|
||||||
|
|
||||||
- 2023-08-16 `t+22`: 15 days later, we’re making the final edits to the Slack workspace.
|
- 2023-08-16 `t+22`: 15 days later, we’re making the final edits to the Slack workspace.
|
||||||
- **Slack**
|
- **Slack**
|
||||||
- [Edit posting permissions](https://app.slack.com/slackhelp/en-US/360004635551) in `#general`
|
- [Edit posting permissions](https://app.slack.com/slackhelp/en-US/360004635551) in `#general`
|
||||||
- Disconnect Slack
|
- Disconnect Slack
|
||||||
|
|
||||||
## Final thoughts
|
## Final thoughts
|
||||||
|
|
||||||
- We’re at the very, early stage on our journey to building a beautiful, open-source DocuSign alternative. We want to build a great developer experience with the open-source community and, switching to Discord, we want to set up the foundations of an open, safe place for community members to get in touch, brainstorm ideas, and have fun.
|
- We’re at the very, early stage on our journey to building a beautiful, open-source DocuSign alternative. We want to build a great developer experience with the open-source community and, switching to Discord, we want to set up the foundations of an open, safe place for community members to get in touch, brainstorm ideas, and have fun.
|
||||||
- It doesn’t mean we won’t ever switch back to Slack. The tools of today aren’t the ones of tomorrow. We don’t delete the Slack workspace, we archive it, and keep the `documenso` handle. May it be just an *au revoir?*
|
- It doesn’t mean we won’t ever switch back to Slack. The tools of today aren’t the ones of tomorrow. We don’t delete the Slack workspace, we archive it, and keep the `documenso` handle. May it be just an _au revoir?_
|
||||||
- For now, we’re pushing forward and are eager to welcome you on Discord. Make sure to [join the server](https://documen.so/discord) in order to keep up to date on all things Documenso. See you there!
|
- For now, we’re pushing forward and are eager to welcome you on Discord. Make sure to [join the server](https://documen.so/discord) in order to keep up to date on all things Documenso. See you there!
|
||||||
|
|||||||
13
apps/marketing/content/careers.mdx
Normal file
13
apps/marketing/content/careers.mdx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
title: Careers at Documenso
|
||||||
|
---
|
||||||
|
|
||||||
|
# Careers at Documenso
|
||||||
|
|
||||||
|
So you love Documenso and all the things that we do and now you want to work with us to unlock the future of open signing?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Positions
|
||||||
|
|
||||||
|
Unfortunately we have no open positions available at the moment. Our team has grown and so we must grow with it, please check back from time to time as now is not forever and we may be hiring again in the future.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { withContentlayer } = require('next-contentlayer');
|
const { withContentlayer } = require('next-contentlayer');
|
||||||
|
|
||||||
@@ -10,15 +11,31 @@ ENV_FILES.forEach((file) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// !: This is a temp hack to get caveat working without placing it back in the public directory.
|
||||||
|
// !: By inlining this at build time we should be able to sign faster.
|
||||||
|
const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||||
|
);
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActionsBodySizeLimit: '10mb',
|
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '50mb',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
transpilePackages: [
|
||||||
|
'@documenso/assets',
|
||||||
|
'@documenso/lib',
|
||||||
|
'@documenso/tailwind-config',
|
||||||
|
'@documenso/trpc',
|
||||||
|
'@documenso/ui',
|
||||||
|
],
|
||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_PROJECT: 'marketing',
|
NEXT_PUBLIC_PROJECT: 'marketing',
|
||||||
|
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||||
},
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/marketing",
|
"name": "@documenso/marketing",
|
||||||
"version": "0.1.0",
|
"version": "1.2.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,10 +8,12 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3001",
|
"start": "next start -p 3001",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
"lint:fix": "next lint --fix",
|
||||||
"clean": "rimraf .next && rimraf node_modules",
|
"clean": "rimraf .next && rimraf node_modules",
|
||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@documenso/assets": "*",
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
@@ -22,8 +24,8 @@
|
|||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "14.0.0",
|
"next": "14.0.3",
|
||||||
"next-auth": "4.24.3",
|
"next-auth": "4.24.5",
|
||||||
"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",
|
||||||
@@ -42,5 +44,13 @@
|
|||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"next-auth": {
|
||||||
|
"next": "$next"
|
||||||
|
},
|
||||||
|
"next-contentlayer": {
|
||||||
|
"next": "$next"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
apps/marketing/process-env.d.ts
vendored
2
apps/marketing/process-env.d.ts
vendored
@@ -6,8 +6,6 @@ declare namespace NodeJS {
|
|||||||
NEXT_PRIVATE_DATABASE_URL: string;
|
NEXT_PRIVATE_DATABASE_URL: string;
|
||||||
|
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
|
||||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
|
|
||||||
|
|
||||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||||
|
|||||||
BIN
apps/marketing/public/blog/roadmap.png
Normal file
BIN
apps/marketing/public/blog/roadmap.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/marketing/public/blog/sp5.png
Normal file
BIN
apps/marketing/public/blog/sp5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -4,13 +4,13 @@ import { allBlogPosts } from 'contentlayer/generated';
|
|||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
export const size = {
|
export const contentType = 'image/png';
|
||||||
|
|
||||||
|
export const IMAGE_SIZE = {
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const contentType = 'image/png';
|
|
||||||
|
|
||||||
type BlogPostOpenGraphImageProps = {
|
type BlogPostOpenGraphImageProps = {
|
||||||
params: { post: string };
|
params: { post: string };
|
||||||
};
|
};
|
||||||
@@ -25,16 +25,16 @@ export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGra
|
|||||||
// The long urls are needed for a compiler optimisation on the Next.js side, lifting this up
|
// The long urls are needed for a compiler optimisation on the Next.js side, lifting this up
|
||||||
// to a constant will break og image generation.
|
// to a constant will break og image generation.
|
||||||
const [interBold, interRegular, backgroundImage, logoImage] = await Promise.all([
|
const [interBold, interRegular, backgroundImage, logoImage] = await Promise.all([
|
||||||
fetch(new URL('./../../../../assets/inter-bold.ttf', import.meta.url)).then(async (res) =>
|
fetch(new URL('@documenso/assets/fonts/inter-bold.ttf', import.meta.url)).then(async (res) =>
|
||||||
res.arrayBuffer(),
|
res.arrayBuffer(),
|
||||||
),
|
),
|
||||||
fetch(new URL('./../../../../assets/inter-regular.ttf', import.meta.url)).then(async (res) =>
|
fetch(new URL('@documenso/assets/fonts/inter-regular.ttf', import.meta.url)).then(async (res) =>
|
||||||
res.arrayBuffer(),
|
res.arrayBuffer(),
|
||||||
),
|
),
|
||||||
fetch(new URL('./../../../../assets/background-blog-og.png', import.meta.url)).then(
|
fetch(new URL('@documenso/assets/images/background-blog-og.png', import.meta.url)).then(
|
||||||
async (res) => res.arrayBuffer(),
|
async (res) => res.arrayBuffer(),
|
||||||
),
|
),
|
||||||
fetch(new URL('./../../../../../public/logo.png', import.meta.url)).then(async (res) =>
|
fetch(new URL('@documenso/assets/logo.png', import.meta.url)).then(async (res) =>
|
||||||
res.arrayBuffer(),
|
res.arrayBuffer(),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -56,7 +56,7 @@ export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGra
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
...size,
|
...IMAGE_SIZE,
|
||||||
fonts: [
|
fonts: [
|
||||||
{
|
{
|
||||||
name: 'Inter',
|
name: 'Inter',
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('relative max-w-[100vw] pt-20 md:pt-28', {
|
className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', {
|
||||||
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
|
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
@@ -41,7 +41,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
|||||||
<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 flex-1 px-4 lg:px-8">{children}</div>
|
||||||
|
|
||||||
<Footer className="bg-background border-muted mt-24 border-t" />
|
<Footer className="bg-background border-muted mt-24 border-t" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
export type MonthlyNewUsersChartProps = {
|
export type MonthlyNewUsersChartProps = {
|
||||||
@@ -22,7 +22,7 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
|
|||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<div className={cn('flex flex-col', className)}>
|
||||||
<div className="flex items-center px-4">
|
<div className="flex items-center px-4">
|
||||||
<h3 className="text-lg font-semibold">Monthly New Users</h3>
|
<h3 className="text-lg font-semibold">New Users</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
export type MonthlyTotalUsersChartProps = {
|
export type MonthlyTotalUsersChartProps = {
|
||||||
@@ -22,7 +22,7 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
|
|||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<div className={cn('flex flex-col', className)}>
|
||||||
<div className="flex items-center px-4">
|
<div className="flex items-center px-4">
|
||||||
<h3 className="text-lg font-semibold">Monthly Total Users</h3>
|
<h3 className="text-lg font-semibold">Total Users</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export const revalidate = 3600;
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const GITHUB_HEADERS: Record<string, string> = {
|
||||||
|
accept: 'application/vnd.github.v3+json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.NEXT_PRIVATE_GITHUB_TOKEN) {
|
||||||
|
GITHUB_HEADERS.authorization = `Bearer ${process.env.NEXT_PRIVATE_GITHUB_TOKEN}`;
|
||||||
|
}
|
||||||
|
|
||||||
const ZGithubStatsResponse = z.object({
|
const ZGithubStatsResponse = z.object({
|
||||||
stargazers_count: z.number(),
|
stargazers_count: z.number(),
|
||||||
forks_count: z.number(),
|
forks_count: z.number(),
|
||||||
@@ -28,6 +36,10 @@ const ZMergedPullRequestsResponse = z.object({
|
|||||||
total_count: z.number(),
|
total_count: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ZOpenIssuesResponse = z.object({
|
||||||
|
total_count: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
const ZStargazersLiveResponse = z.record(
|
const ZStargazersLiveResponse = z.record(
|
||||||
z.object({
|
z.object({
|
||||||
stars: z.number(),
|
stars: z.number(),
|
||||||
@@ -48,49 +60,76 @@ const ZEarlyAdoptersResponse = z.record(
|
|||||||
export type StargazersType = z.infer<typeof ZStargazersLiveResponse>;
|
export type StargazersType = z.infer<typeof ZStargazersLiveResponse>;
|
||||||
export type EarlyAdoptersType = z.infer<typeof ZEarlyAdoptersResponse>;
|
export type EarlyAdoptersType = z.infer<typeof ZEarlyAdoptersResponse>;
|
||||||
|
|
||||||
export default async function OpenPage() {
|
const fetchGithubStats = async () => {
|
||||||
const GITHUB_HEADERS: Record<string, string> = {
|
return await fetch('https://api.github.com/repos/documenso/documenso', {
|
||||||
accept: 'application/vnd.github.v3+json',
|
headers: {
|
||||||
};
|
...GITHUB_HEADERS,
|
||||||
|
},
|
||||||
if (process.env.NEXT_PRIVATE_GITHUB_TOKEN) {
|
|
||||||
GITHUB_HEADERS.authorization = `Bearer ${process.env.NEXT_PRIVATE_GITHUB_TOKEN}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
forks_count: forksCount,
|
|
||||||
open_issues: openIssues,
|
|
||||||
stargazers_count: stargazersCount,
|
|
||||||
} = await fetch('https://api.github.com/repos/documenso/documenso', {
|
|
||||||
headers: GITHUB_HEADERS,
|
|
||||||
})
|
})
|
||||||
.then(async (res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZGithubStatsResponse.parse(res));
|
.then((res) => ZGithubStatsResponse.parse(res));
|
||||||
|
};
|
||||||
|
|
||||||
const { total_count: mergedPullRequests } = await fetch(
|
const fetchOpenIssues = async () => {
|
||||||
|
return await fetch(
|
||||||
|
'https://api.github.com/search/issues?q=repo:documenso/documenso+type:issue+state:open&page=0&per_page=1',
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
...GITHUB_HEADERS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(async (res) => res.json())
|
||||||
|
.then((res) => ZOpenIssuesResponse.parse(res));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMergedPullRequests = async () => {
|
||||||
|
return await fetch(
|
||||||
'https://api.github.com/search/issues?q=repo:documenso/documenso/+is:pr+merged:>=2010-01-01&page=0&per_page=1',
|
'https://api.github.com/search/issues?q=repo:documenso/documenso/+is:pr+merged:>=2010-01-01&page=0&per_page=1',
|
||||||
{
|
{
|
||||||
headers: GITHUB_HEADERS,
|
headers: {
|
||||||
|
...GITHUB_HEADERS,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.then(async (res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZMergedPullRequestsResponse.parse(res));
|
.then((res) => ZMergedPullRequestsResponse.parse(res));
|
||||||
|
};
|
||||||
|
|
||||||
const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', {
|
const fetchStargazers = async () => {
|
||||||
|
return await fetch('https://stargrazer-live.onrender.com/api/stats', {
|
||||||
headers: {
|
headers: {
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async (res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZStargazersLiveResponse.parse(res));
|
.then((res) => ZStargazersLiveResponse.parse(res));
|
||||||
|
};
|
||||||
|
|
||||||
const EARLY_ADOPTERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
|
const fetchEarlyAdopters = async () => {
|
||||||
|
return await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
|
||||||
headers: {
|
headers: {
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async (res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZEarlyAdoptersResponse.parse(res));
|
.then((res) => ZEarlyAdoptersResponse.parse(res));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function OpenPage() {
|
||||||
|
const [
|
||||||
|
{ forks_count: forksCount, stargazers_count: stargazersCount },
|
||||||
|
{ total_count: openIssues },
|
||||||
|
{ total_count: mergedPullRequests },
|
||||||
|
STARGAZERS_DATA,
|
||||||
|
EARLY_ADOPTERS_DATA,
|
||||||
|
] = await Promise.all([
|
||||||
|
fetchGithubStats(),
|
||||||
|
fetchOpenIssues(),
|
||||||
|
fetchMergedPullRequests(),
|
||||||
|
fetchStargazers(),
|
||||||
|
fetchEarlyAdopters(),
|
||||||
|
]);
|
||||||
|
|
||||||
const MONTHLY_USERS = await getUserMonthlyGrowth();
|
const MONTHLY_USERS = await getUserMonthlyGrowth();
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,7 @@ export function OpenPageTooltip() {
|
|||||||
</svg>
|
</svg>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>Active Subscriptions.</p>
|
||||||
August and earlier: Active subscribers. September and beyond: Numbers of active
|
|
||||||
subscriptions.
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Image from 'next/image';
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||||
|
|
||||||
import { OSSFriendsContainer } from './container';
|
import { OSSFriendsContainer } from './container';
|
||||||
import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema';
|
import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -8,24 +8,23 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { DocumentDataType, Field, Prisma, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
||||||
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
||||||
import {
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
DocumentFlowFormContainer,
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
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 { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
|
const SinglePlayerModeSteps = ['fields', 'sign'] as const;
|
||||||
|
type SinglePlayerModeStep = (typeof SinglePlayerModeSteps)[number];
|
||||||
type SinglePlayerModeStep = 'fields' | 'sign';
|
|
||||||
|
|
||||||
// !: This entire file is a hack to get around failed prerendering of
|
// !: This entire file is a hack to get around failed prerendering of
|
||||||
// !: the Single Player Mode page. This regression was introduced during
|
// !: the Single Player Mode page. This regression was introduced during
|
||||||
@@ -41,6 +40,9 @@ export const SinglePlayerClient = () => {
|
|||||||
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
|
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
|
||||||
const [fields, setFields] = useState<Field[]>([]);
|
const [fields, setFields] = useState<Field[]>([]);
|
||||||
|
|
||||||
|
const { mutateAsync: createSinglePlayerDocument } =
|
||||||
|
trpc.singleplayer.createSinglePlayerDocument.useMutation();
|
||||||
|
|
||||||
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Add document',
|
title: 'Add document',
|
||||||
@@ -84,6 +86,7 @@ export const SinglePlayerClient = () => {
|
|||||||
data.fields.map((field, i) => ({
|
data.fields.map((field, i) => ({
|
||||||
id: i,
|
id: i,
|
||||||
documentId: -1,
|
documentId: -1,
|
||||||
|
templateId: null,
|
||||||
recipientId: -1,
|
recipientId: -1,
|
||||||
type: field.type,
|
type: field.type,
|
||||||
page: field.pageNumber,
|
page: field.pageNumber,
|
||||||
@@ -146,6 +149,7 @@ export const SinglePlayerClient = () => {
|
|||||||
const placeholderRecipient: Recipient = {
|
const placeholderRecipient: Recipient = {
|
||||||
id: -1,
|
id: -1,
|
||||||
documentId: -1,
|
documentId: -1,
|
||||||
|
templateId: null,
|
||||||
email: '',
|
email: '',
|
||||||
name: '',
|
name: '',
|
||||||
token: '',
|
token: '',
|
||||||
@@ -223,37 +227,35 @@ export const SinglePlayerClient = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
<DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
|
<DocumentFlowFormContainer
|
||||||
<DocumentFlowFormContainerHeader
|
className="top-24 lg:h-[calc(100vh-7rem)]"
|
||||||
title={currentDocumentFlow.title}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
description={currentDocumentFlow.description}
|
>
|
||||||
/>
|
<Stepper
|
||||||
|
currentStep={currentDocumentFlow.stepIndex}
|
||||||
{/* Add fields to PDF page. */}
|
setCurrentStep={(step) => setStep(SinglePlayerModeSteps[step - 1])}
|
||||||
{step === 'fields' && (
|
>
|
||||||
|
{/* Add fields to PDF page. */}
|
||||||
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
|
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
|
||||||
<AddFieldsFormPartial
|
<AddFieldsFormPartial
|
||||||
documentFlow={documentFlow.fields}
|
documentFlow={documentFlow.fields}
|
||||||
hideRecipients={true}
|
hideRecipients={true}
|
||||||
recipients={uploadedFile ? [placeholderRecipient] : []}
|
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||||
numberOfSteps={Object.keys(documentFlow).length}
|
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onFieldsSubmit}
|
onSubmit={onFieldsSubmit}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Enter user details and signature. */}
|
{/* Enter user details and signature. */}
|
||||||
{step === 'sign' && (
|
|
||||||
<AddSignatureFormPartial
|
<AddSignatureFormPartial
|
||||||
documentFlow={documentFlow.sign}
|
documentFlow={documentFlow.sign}
|
||||||
numberOfSteps={Object.keys(documentFlow).length}
|
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onSignSubmit}
|
onSubmit={onSignSubmit}
|
||||||
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
||||||
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
||||||
/>
|
/>
|
||||||
)}
|
</Stepper>
|
||||||
</DocumentFlowFormContainer>
|
</DocumentFlowFormContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||||
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 backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Info } from 'lucide-react';
|
|
||||||
import { usePlausible } from 'next-plausible';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
|
||||||
|
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
|
||||||
|
|
||||||
export const ZClaimPlanDialogFormSchema = z.object({
|
|
||||||
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
|
|
||||||
email: z.string().email(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TClaimPlanDialogFormSchema = z.infer<typeof ZClaimPlanDialogFormSchema>;
|
|
||||||
|
|
||||||
export type ClaimPlanDialogProps = {
|
|
||||||
className?: string;
|
|
||||||
planId: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
|
|
||||||
const params = useSearchParams();
|
|
||||||
const analytics = useAnalytics();
|
|
||||||
const event = usePlausible();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
reset,
|
|
||||||
} = useForm<TClaimPlanDialogFormSchema>({
|
|
||||||
defaultValues: {
|
|
||||||
name: params?.get('name') ?? '',
|
|
||||||
email: params?.get('email') ?? '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZClaimPlanDialogFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
|
|
||||||
try {
|
|
||||||
const delay = new Promise<void>((resolve) => {
|
|
||||||
setTimeout(resolve, 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [redirectUrl] = await Promise.all([
|
|
||||||
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
|
|
||||||
delay,
|
|
||||||
]);
|
|
||||||
|
|
||||||
event('claim-plan-pricing');
|
|
||||||
analytics.capture('Marketing: Claim plan', { planId, email });
|
|
||||||
|
|
||||||
window.location.href = redirectUrl;
|
|
||||||
} catch (error) {
|
|
||||||
event('claim-plan-failed');
|
|
||||||
analytics.capture('Marketing: Claim plan failure', { planId, email });
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: error instanceof Error ? error.message : 'Please try again later.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isSubmitting && !open) {
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Claim your plan</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
We're almost there! Please enter your email address and name to claim your plan.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset disabled={isSubmitting} className={cn('flex flex-col gap-y-4', className)}>
|
|
||||||
{params?.get('cancelled') === 'true' && (
|
|
||||||
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Info className="h-5 w-5 text-yellow-400" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<p className="text-sm leading-5 text-yellow-700">
|
|
||||||
You have cancelled the payment process. If you didn't mean to do this, please
|
|
||||||
try again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground">Name</Label>
|
|
||||||
|
|
||||||
<Input type="text" className="mt-2" {...register('name')} autoFocus />
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-1" error={errors.name} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground">Email</Label>
|
|
||||||
|
|
||||||
<Input type="email" className="mt-2" {...register('email')} />
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-1" error={errors.email} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" size="lg" loading={isSubmitting}>
|
|
||||||
Claim the early adopters Plan (
|
|
||||||
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
|
|
||||||
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
|
||||||
? 'Monthly'
|
|
||||||
: 'Yearly'}
|
|
||||||
)
|
|
||||||
</Button>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,14 +2,13 @@ import { HTMLAttributes } from 'react';
|
|||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||||
|
import cardBeautifulFigure from '@documenso/assets/images/card-beautiful-figure.png';
|
||||||
|
import cardFastFigure from '@documenso/assets/images/card-fast-figure.png';
|
||||||
|
import cardSmartFigure from '@documenso/assets/images/card-smart-figure.png';
|
||||||
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 backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
import cardBeautifulFigure from '~/assets/card-beautiful-figure.png';
|
|
||||||
import cardFastFigure from '~/assets/card-fast-figure.png';
|
|
||||||
import cardSmartFigure from '~/assets/card-smart-figure.png';
|
|
||||||
|
|
||||||
export type FasterSmarterBeautifulBentoProps = HTMLAttributes<HTMLDivElement>;
|
export type FasterSmarterBeautifulBentoProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const FasterSmarterBeautifulBento = ({
|
export const FasterSmarterBeautifulBento = ({
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Moon, Sun } from 'lucide-react';
|
|
||||||
import { useTheme } from 'next-themes';
|
|
||||||
import { FaXTwitter } from 'react-icons/fa6';
|
import { FaXTwitter } from 'react-icons/fa6';
|
||||||
import { LiaDiscord } from 'react-icons/lia';
|
import { LiaDiscord } from 'react-icons/lia';
|
||||||
import { LuGithub } from 'react-icons/lu';
|
import { LuGithub } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import LogoImage from '@documenso/assets/logo.png';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||||
|
|
||||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
@@ -26,23 +26,23 @@ const FOOTER_LINKS = [
|
|||||||
{ href: '/singleplayer', text: 'Singleplayer' },
|
{ href: '/singleplayer', text: 'Singleplayer' },
|
||||||
{ href: '/blog', text: 'Blog' },
|
{ href: '/blog', text: 'Blog' },
|
||||||
{ href: '/design-system', text: 'Design' },
|
{ href: '/design-system', text: 'Design' },
|
||||||
{ href: '/open', text: 'Open' },
|
{ href: '/open', text: 'Open Startup' },
|
||||||
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
||||||
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
|
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
|
||||||
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
|
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
|
||||||
|
{ href: '/oss-friends', text: 'OSS Friends' },
|
||||||
|
{ href: '/careers', text: 'Careers' },
|
||||||
{ href: '/privacy', text: 'Privacy' },
|
{ href: '/privacy', text: 'Privacy' },
|
||||||
];
|
];
|
||||||
|
|
||||||
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
|
<Image
|
||||||
src="/logo.png"
|
src={LogoImage}
|
||||||
alt="Documenso Logo"
|
alt="Documenso Logo"
|
||||||
className="dark:invert"
|
className="dark:invert"
|
||||||
width={170}
|
width={170}
|
||||||
@@ -77,21 +77,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap items-center justify-between gap-4 px-8 md:mt-12 lg:mt-24">
|
||||||
<p className="text-muted-foreground text-sm">
|
<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">
|
<div className="flex flex-wrap">
|
||||||
<button type="button" className="text-muted-foreground" onClick={() => setTheme('light')}>
|
<ThemeSwitcher />
|
||||||
<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>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes, useState } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import LogoImage from '@documenso/assets/logo.png';
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
|||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src={LogoImage}
|
||||||
alt="Documenso Logo"
|
alt="Documenso Logo"
|
||||||
className="dark:invert"
|
className="dark:invert"
|
||||||
width={170}
|
width={170}
|
||||||
@@ -62,7 +64,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
|||||||
href="/open"
|
href="/open"
|
||||||
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||||
>
|
>
|
||||||
Open
|
Open Startup
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import { usePlausible } from 'next-plausible';
|
|||||||
import { LuGithub } from 'react-icons/lu';
|
import { LuGithub } from 'react-icons/lu';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
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';
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
|
|
||||||
import { Widget } from './widget';
|
import { Widget } from './widget';
|
||||||
|
|
||||||
export type HeroProps = {
|
export type HeroProps = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { FaXTwitter } from 'react-icons/fa6';
|
|||||||
import { LiaDiscord } from 'react-icons/lia';
|
import { LiaDiscord } from 'react-icons/lia';
|
||||||
import { LuGithub } from 'react-icons/lu';
|
import { LuGithub } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import LogoImage from '@documenso/assets/logo.png';
|
||||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||||
|
|
||||||
export type MobileNavigationProps = {
|
export type MobileNavigationProps = {
|
||||||
@@ -30,7 +31,7 @@ export const MENU_NAVIGATION_LINKS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/open',
|
href: '/open',
|
||||||
text: 'Open',
|
text: 'Open Startup',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: 'https://status.documenso.com',
|
href: 'https://status.documenso.com',
|
||||||
@@ -63,7 +64,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
<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
|
<Image
|
||||||
src="/logo.png"
|
src={LogoImage}
|
||||||
alt="Documenso Logo"
|
alt="Documenso Logo"
|
||||||
className="dark:invert"
|
className="dark:invert"
|
||||||
width={170}
|
width={170}
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ import { HTMLAttributes } from 'react';
|
|||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||||
|
import cardBuildFigure from '@documenso/assets/images/card-build-figure.png';
|
||||||
|
import cardOpenFigure from '@documenso/assets/images/card-open-figure.png';
|
||||||
|
import cardTemplateFigure from '@documenso/assets/images/card-template-figure.png';
|
||||||
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 backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
import cardBuildFigure from '~/assets/card-build-figure.png';
|
|
||||||
import cardOpenFigure from '~/assets/card-open-figure.png';
|
|
||||||
import cardTemplateFigure from '~/assets/card-template-figure.png';
|
|
||||||
|
|
||||||
export type OpenBuildTemplateBentoProps = HTMLAttributes<HTMLDivElement>;
|
export type OpenBuildTemplateBentoProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplateBentoProps) => {
|
export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplateBentoProps) => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes, useState } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { usePlausible } from 'next-plausible';
|
import { usePlausible } from 'next-plausible';
|
||||||
@@ -16,14 +16,9 @@ export type PricingTableProps = HTMLAttributes<HTMLDivElement>;
|
|||||||
const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar';
|
const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar';
|
||||||
|
|
||||||
export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||||
const params = useSearchParams();
|
|
||||||
const event = usePlausible();
|
const event = usePlausible();
|
||||||
|
|
||||||
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() =>
|
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>('MONTHLY');
|
||||||
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
|
|
||||||
? 'YEARLY'
|
|
||||||
: 'MONTHLY',
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('', className)} {...props}>
|
<div className={cn('', className)} {...props}>
|
||||||
|
|||||||
@@ -2,15 +2,14 @@ import { HTMLAttributes } from 'react';
|
|||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||||
|
import cardConnectionsFigure from '@documenso/assets/images/card-connections-figure.png';
|
||||||
|
import cardPaidFigure from '@documenso/assets/images/card-paid-figure.png';
|
||||||
|
import cardSharingFigure from '@documenso/assets/images/card-sharing-figure.png';
|
||||||
|
import cardWidgetFigure from '@documenso/assets/images/card-widget-figure.png';
|
||||||
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 backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
import cardConnectionsFigure from '~/assets/card-connections-figure.png';
|
|
||||||
import cardPaidFigure from '~/assets/card-paid-figure.png';
|
|
||||||
import cardSharingFigure from '~/assets/card-sharing-figure.png';
|
|
||||||
import cardWidgetFigure from '~/assets/card-widget-figure.png';
|
|
||||||
|
|
||||||
export type ShareConnectPaidWidgetBentoProps = HTMLAttributes<HTMLDivElement>;
|
export type ShareConnectPaidWidgetBentoProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const ShareConnectPaidWidgetBento = ({
|
export const ShareConnectPaidWidgetBento = ({
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
'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 { putFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import {
|
|
||||||
DocumentDataType,
|
|
||||||
DocumentStatus,
|
|
||||||
FieldType,
|
|
||||||
Prisma,
|
|
||||||
ReadStatus,
|
|
||||||
SendStatus,
|
|
||||||
SigningStatus,
|
|
||||||
} from '@documenso/prisma/client';
|
|
||||||
import { signPdf } from '@documenso/signing';
|
|
||||||
|
|
||||||
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 unsignedPdfBytes = await doc.save();
|
|
||||||
|
|
||||||
const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) });
|
|
||||||
|
|
||||||
const { token } = await prisma.$transaction(
|
|
||||||
async (tx) => {
|
|
||||||
const token = alphaid();
|
|
||||||
|
|
||||||
// Fetch service user who will be the owner of the document.
|
|
||||||
const serviceUser = await tx.user.findFirstOrThrow({
|
|
||||||
where: {
|
|
||||||
email: SERVICE_USER_EMAIL,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { id: documentDataId } = await putFile({
|
|
||||||
name: `${documentName}.pdf`,
|
|
||||||
type: 'application/pdf',
|
|
||||||
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
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 { document, token };
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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: signedPdfBuffer, filename: documentName }],
|
|
||||||
});
|
|
||||||
|
|
||||||
return token;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -4,9 +4,11 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { DocumentStatus, Signature } from '@documenso/prisma/client';
|
import type { Signature } from '@documenso/prisma/client';
|
||||||
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||||
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
||||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
@@ -14,7 +16,6 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
|||||||
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 signingCelebration from '~/assets/signing-celebration.png';
|
|
||||||
import { ConfettiScreen } from '~/components/(marketing)/confetti-screen';
|
import { ConfettiScreen } from '~/components/(marketing)/confetti-screen';
|
||||||
|
|
||||||
interface SinglePlayerModeSuccessProps {
|
interface SinglePlayerModeSuccessProps {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
import { claimPlan } from '~/api/claim-plan/fetcher';
|
||||||
|
|
||||||
|
import { STEP } from '../constants';
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
import { FormErrorMessage } from '../form/form-error-message';
|
||||||
|
|
||||||
const ZWidgetFormSchema = z
|
const ZWidgetFormSchema = z
|
||||||
@@ -48,13 +49,16 @@ const ZWidgetFormSchema = z
|
|||||||
|
|
||||||
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
|
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
|
||||||
|
|
||||||
|
type StepKeys = keyof typeof STEP;
|
||||||
|
type StepValues = (typeof STEP)[StepKeys];
|
||||||
|
|
||||||
export type WidgetProps = HTMLAttributes<HTMLDivElement>;
|
export type WidgetProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const event = usePlausible();
|
const event = usePlausible();
|
||||||
|
|
||||||
const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL');
|
const [step, setStep] = useState<StepValues>(STEP.EMAIL);
|
||||||
const [showSigningDialog, setShowSigningDialog] = useState(false);
|
const [showSigningDialog, setShowSigningDialog] = useState(false);
|
||||||
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
|
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -81,11 +85,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
const signatureText = watch('signatureText');
|
const signatureText = watch('signatureText');
|
||||||
|
|
||||||
const stepsRemaining = useMemo(() => {
|
const stepsRemaining = useMemo(() => {
|
||||||
if (step === 'NAME') {
|
if (step === STEP.NAME) {
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step === 'SIGN') {
|
if (step === STEP.EMAIL) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,16 +97,16 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
}, [step]);
|
}, [step]);
|
||||||
|
|
||||||
const onNextStepClick = () => {
|
const onNextStepClick = () => {
|
||||||
if (step === 'EMAIL') {
|
if (step === STEP.EMAIL) {
|
||||||
setStep('NAME');
|
setStep(STEP.NAME);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.querySelector<HTMLElement>('#name')?.focus();
|
document.querySelector<HTMLElement>('#name')?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step === 'NAME') {
|
if (step === STEP.NAME) {
|
||||||
setStep('SIGN');
|
setStep(STEP.SIGN);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.querySelector<HTMLElement>('#signatureText')?.focus();
|
document.querySelector<HTMLElement>('#signatureText')?.focus();
|
||||||
@@ -226,7 +230,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="bg-primary h-full w-14 rounded"
|
className="bg-primary h-full w-14 rounded"
|
||||||
disabled={!field.value || !!errors.email?.message}
|
disabled={!field.value || !!errors.email?.message}
|
||||||
onClick={() => step === 'EMAIL' && onNextStepClick()}
|
onClick={() => step === STEP.EMAIL && onNextStepClick()}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
@@ -238,7 +242,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
<FormErrorMessage error={errors.email} className="mt-1" />
|
<FormErrorMessage error={errors.email} className="mt-1" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{(step === 'NAME' || step === 'SIGN') && (
|
{(step === STEP.NAME || step === STEP.SIGN) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="name"
|
key="name"
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
|
|||||||
5
apps/marketing/src/components/constants.ts
Normal file
5
apps/marketing/src/components/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const STEP = {
|
||||||
|
EMAIL: 'EMAIL',
|
||||||
|
NAME: 'NAME',
|
||||||
|
SIGN: 'SIGN',
|
||||||
|
} as const;
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { buffer } from 'micro';
|
import { buffer } from 'micro';
|
||||||
|
|
||||||
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
|
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
|
||||||
@@ -88,7 +87,11 @@ 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 bytes64 = await fetch(
|
||||||
|
new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url),
|
||||||
|
)
|
||||||
|
.then(async (res) => res.arrayBuffer())
|
||||||
|
.then((buffer) => Buffer.from(buffer).toString('base64'));
|
||||||
|
|
||||||
const { id: documentDataId } = await prisma.documentData.create({
|
const { id: documentDataId } = await prisma.documentData.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import * as trpcNext from '@documenso/trpc/server/adapters/next';
|
|||||||
import { createTrpcContext } from '@documenso/trpc/server/context';
|
import { createTrpcContext } from '@documenso/trpc/server/context';
|
||||||
import { appRouter } from '@documenso/trpc/server/router';
|
import { appRouter } from '@documenso/trpc/server/router';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
maxDuration: 60,
|
||||||
|
api: {
|
||||||
|
bodyParser: {
|
||||||
|
sizeLimit: '50mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default trpcNext.createNextApiHandler({
|
export default trpcNext.createNextApiHandler({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { version } = require('./package.json');
|
const { version } = require('./package.json');
|
||||||
|
|
||||||
@@ -10,22 +11,35 @@ ENV_FILES.forEach((file) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// !: This is a temp hack to get caveat working without placing it back in the public directory.
|
||||||
|
// !: By inlining this at build time we should be able to sign faster.
|
||||||
|
const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||||
|
);
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActionsBodySizeLimit: '50mb',
|
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '50mb',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
|
'@documenso/assets',
|
||||||
|
'@documenso/ee',
|
||||||
'@documenso/lib',
|
'@documenso/lib',
|
||||||
'@documenso/prisma',
|
'@documenso/prisma',
|
||||||
|
'@documenso/tailwind-config',
|
||||||
'@documenso/trpc',
|
'@documenso/trpc',
|
||||||
'@documenso/ui',
|
'@documenso/ui',
|
||||||
'@documenso/email',
|
|
||||||
],
|
],
|
||||||
env: {
|
env: {
|
||||||
APP_VERSION: version,
|
APP_VERSION: version,
|
||||||
NEXT_PUBLIC_PROJECT: 'web',
|
NEXT_PUBLIC_PROJECT: 'web',
|
||||||
|
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||||
},
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/web",
|
"name": "@documenso/web",
|
||||||
"version": "0.1.0",
|
"version": "1.2.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,10 +8,13 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
"e2e:prepare": "next build && next start",
|
||||||
|
"lint:fix": "next lint --fix",
|
||||||
"clean": "rimraf .next && rimraf node_modules",
|
"clean": "rimraf .next && rimraf node_modules",
|
||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@documenso/assets": "*",
|
||||||
"@documenso/ee": "*",
|
"@documenso/ee": "*",
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
@@ -25,8 +28,8 @@
|
|||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "14.0.0",
|
"next": "14.0.3",
|
||||||
"next-auth": "4.24.3",
|
"next-auth": "4.24.5",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
@@ -42,6 +45,7 @@
|
|||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
|
"uqr": "^0.1.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -50,5 +54,13 @@
|
|||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"next-auth": {
|
||||||
|
"next": "$next"
|
||||||
|
},
|
||||||
|
"next-contentlayer": {
|
||||||
|
"next": "$next"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
apps/web/process-env.d.ts
vendored
3
apps/web/process-env.d.ts
vendored
@@ -6,8 +6,7 @@ declare namespace NodeJS {
|
|||||||
NEXT_PRIVATE_DATABASE_URL: string;
|
NEXT_PRIVATE_DATABASE_URL: string;
|
||||||
|
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID?: string;
|
||||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
|
|
||||||
|
|
||||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB |
BIN
apps/web/public/static/add-user.png
Normal file
BIN
apps/web/public/static/add-user.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
apps/web/public/static/mail-open.png
Normal file
BIN
apps/web/public/static/mail-open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@@ -7,9 +7,9 @@ import Link from 'next/link';
|
|||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { Document, User } from '@documenso/prisma/client';
|
import type { Document, User } from '@documenso/prisma/client';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
@@ -65,7 +65,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
accessorKey: 'owner',
|
accessorKey: 'owner',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const avatarFallbackText = row.original.User.name
|
const avatarFallbackText = row.original.User.name
|
||||||
? recipientInitials(row.original.User.name)
|
? extractInitials(row.original.User.name)
|
||||||
: row.original.User.email.slice(0, 1).toUpperCase();
|
: row.original.User.email.slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
|
|
||||||
import { AdminNav } from './nav';
|
import { AdminNav } from './nav';
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
|
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -21,6 +20,8 @@ import {
|
|||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { RoleCombobox } from './role-combobox';
|
||||||
|
|
||||||
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
||||||
|
|
||||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||||
@@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Combobox
|
<RoleCombobox
|
||||||
listValues={roles}
|
listValues={roles}
|
||||||
onChange={(values: string[]) => onChange(values)}
|
onChange={(values: string[]) => onChange(values)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Role } from '@documenso/prisma/client';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
} from '@documenso/ui/primitives/command';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||||
|
|
||||||
|
type RoleComboboxProps = {
|
||||||
|
listValues: string[];
|
||||||
|
onChange: (_values: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RoleCombobox = ({ listValues, onChange }: RoleComboboxProps) => {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
|
||||||
|
const dbRoles = Object.values(Role);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setSelectedValues(listValues);
|
||||||
|
}, [listValues]);
|
||||||
|
|
||||||
|
const allRoles = [...new Set([...dbRoles, ...selectedValues])];
|
||||||
|
|
||||||
|
const handleSelect = (currentValue: string) => {
|
||||||
|
let newSelectedValues;
|
||||||
|
if (selectedValues.includes(currentValue)) {
|
||||||
|
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
|
||||||
|
} else {
|
||||||
|
newSelectedValues = [...selectedValues, currentValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedValues(newSelectedValues);
|
||||||
|
onChange(newSelectedValues);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-[200px] justify-between"
|
||||||
|
>
|
||||||
|
{selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={selectedValues.join(', ')} />
|
||||||
|
<CommandEmpty>No value found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{allRoles.map((value: string, i: number) => (
|
||||||
|
<CommandItem key={i} onSelect={() => handleSelect(value)}>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'mr-2 h-4 w-4',
|
||||||
|
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{value}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,7 +8,7 @@ import { Edit, Loader } from 'lucide-react';
|
|||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
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 { Document, Role, Subscription } from '@documenso/prisma/client';
|
import type { Document, Role, Subscription } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
@@ -19,7 +19,7 @@ type UserData = {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
Subscription?: SubscriptionLite | null;
|
Subscription?: SubscriptionLite[] | null;
|
||||||
Document: DocumentLite[];
|
Document: DocumentLite[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,9 +35,16 @@ type UsersDataTableProps = {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
perPage: number;
|
perPage: number;
|
||||||
page: number;
|
page: number;
|
||||||
|
individualPriceIds: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => {
|
export const UsersDataTable = ({
|
||||||
|
users,
|
||||||
|
totalPages,
|
||||||
|
perPage,
|
||||||
|
page,
|
||||||
|
individualPriceIds,
|
||||||
|
}: UsersDataTableProps) => {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
const [searchString, setSearchString] = useState('');
|
const [searchString, setSearchString] = useState('');
|
||||||
@@ -100,7 +107,13 @@ export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTa
|
|||||||
{
|
{
|
||||||
header: 'Subscription',
|
header: 'Subscription',
|
||||||
accessorKey: 'subscription',
|
accessorKey: 'subscription',
|
||||||
cell: ({ row }) => row.original.Subscription?.status ?? 'NONE',
|
cell: ({ row }) => {
|
||||||
|
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
|
||||||
|
individualPriceIds.includes(sub.priceId),
|
||||||
|
);
|
||||||
|
|
||||||
|
return foundIndividualSubscription?.status ?? 'NONE';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Documents',
|
header: 'Documents',
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
|
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
|
||||||
|
|
||||||
export async function search(search: string, page: number, perPage: number) {
|
export async function search(search: string, page: number, perPage: number) {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
if (!isAdmin(user)) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
const results = await findUsers({ username: search, email: search, page, perPage });
|
const results = await findUsers({ username: search, email: search, page, perPage });
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
|
||||||
|
|
||||||
import { UsersDataTable } from './data-table-users';
|
import { UsersDataTable } from './data-table-users';
|
||||||
import { search } from './fetch-users.actions';
|
import { search } from './fetch-users.actions';
|
||||||
|
|
||||||
@@ -14,12 +16,23 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
|
|||||||
const perPage = Number(searchParams.perPage) || 10;
|
const perPage = Number(searchParams.perPage) || 10;
|
||||||
const searchString = searchParams.search || '';
|
const searchString = searchParams.search || '';
|
||||||
|
|
||||||
const { users, totalPages } = await search(searchString, page, perPage);
|
const [{ users, totalPages }, individualPrices] = await Promise.all([
|
||||||
|
search(searchString, page, perPage),
|
||||||
|
getPricesByType('individual'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const individualPriceIds = individualPrices.map((price) => price.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-semibold">Manage users</h2>
|
<h2 className="text-4xl font-semibold">Manage users</h2>
|
||||||
<UsersDataTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
|
<UsersDataTable
|
||||||
|
users={users}
|
||||||
|
individualPriceIds={individualPriceIds}
|
||||||
|
totalPages={totalPages}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
|
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
import type { Team } from '@documenso/prisma/client';
|
||||||
|
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
|
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||||
|
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
|
export type DocumentPageComponentProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
team?: Team;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DocumentPageComponent({ params, team }: DocumentPageComponentProps) {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const documentId = Number(id);
|
||||||
|
|
||||||
|
const documentRootPath = team ? `/t/${team.url}/documents` : '/documents';
|
||||||
|
|
||||||
|
if (!documentId || Number.isNaN(documentId)) {
|
||||||
|
redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: documentId,
|
||||||
|
userId: user.id,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!document || !document.documentData) {
|
||||||
|
redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { documentData } = document;
|
||||||
|
|
||||||
|
const [recipients, fields] = await Promise.all([
|
||||||
|
await getRecipientsForDocument({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
await getFieldsForDocument({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
Documents
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
|
{document.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
||||||
|
|
||||||
|
{recipients.length > 0 && (
|
||||||
|
<div className="text-muted-foreground flex items-center">
|
||||||
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
|
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||||
|
<span>{recipients.length} Recipient(s)</span>
|
||||||
|
</StackAvatarsWithTooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{document.status !== InternalDocumentStatus.COMPLETED && (
|
||||||
|
<EditDocumentForm
|
||||||
|
className="mt-8"
|
||||||
|
document={document}
|
||||||
|
user={user}
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
documentData={documentData}
|
||||||
|
documentRootPath={documentRootPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||||
|
<div className="mx-auto mt-12 max-w-2xl">
|
||||||
|
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,28 +4,26 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
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';
|
||||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
||||||
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||||
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
||||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||||
import {
|
import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title';
|
||||||
DocumentFlowFormContainer,
|
import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types';
|
||||||
DocumentFlowFormContainerHeader,
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { addFields } from '~/components/forms/edit-document/add-fields.action';
|
|
||||||
import { addSigners } from '~/components/forms/edit-document/add-signers.action';
|
|
||||||
import { completeDocument } from '~/components/forms/edit-document/add-subject.action';
|
|
||||||
|
|
||||||
export type EditDocumentFormProps = {
|
export type EditDocumentFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
user: User;
|
||||||
@@ -33,9 +31,11 @@ export type EditDocumentFormProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
|
documentRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
||||||
|
const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'subject'];
|
||||||
|
|
||||||
export const EditDocumentForm = ({
|
export const EditDocumentForm = ({
|
||||||
className,
|
className,
|
||||||
@@ -44,33 +44,65 @@ export const EditDocumentForm = ({
|
|||||||
fields,
|
fields,
|
||||||
user: _user,
|
user: _user,
|
||||||
documentData,
|
documentData,
|
||||||
|
documentRootPath,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [step, setStep] = useState<EditDocumentStep>('signers');
|
// controlled stepper state
|
||||||
|
const [step, setStep] = useState<EditDocumentStep>(
|
||||||
|
document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
|
||||||
|
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
||||||
|
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
|
||||||
|
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
|
||||||
|
|
||||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
|
title: {
|
||||||
|
title: 'Add Title',
|
||||||
|
description: 'Add the title to the document.',
|
||||||
|
stepIndex: 1,
|
||||||
|
},
|
||||||
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: 2,
|
||||||
},
|
},
|
||||||
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: 3,
|
||||||
onBackStep: () => setStep('signers'),
|
|
||||||
},
|
},
|
||||||
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: 4,
|
||||||
onBackStep: () => setStep('fields'),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentDocumentFlow = documentFlow[step];
|
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
|
||||||
|
try {
|
||||||
|
// Custom invocation server action
|
||||||
|
await addTitle({
|
||||||
|
documentId: document.id,
|
||||||
|
title: data.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
setStep('signers');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while updating title.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||||
try {
|
try {
|
||||||
@@ -81,7 +113,6 @@ export const EditDocumentForm = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
setStep('fields');
|
setStep('fields');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -103,7 +134,6 @@ export const EditDocumentForm = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
setStep('subject');
|
setStep('subject');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -120,7 +150,7 @@ export const EditDocumentForm = ({
|
|||||||
const { subject, message } = data.email;
|
const { subject, message } = data.email;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await completeDocument({
|
await sendDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
email: {
|
email: {
|
||||||
subject,
|
subject,
|
||||||
@@ -134,7 +164,7 @@ export const EditDocumentForm = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push('/documents');
|
router.push(documentRootPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@@ -146,6 +176,8 @@ export const EditDocumentForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
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
|
||||||
@@ -158,44 +190,47 @@ export const EditDocumentForm = ({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
|
<DocumentFlowFormContainer
|
||||||
<DocumentFlowFormContainerHeader
|
className="lg:h-[calc(100vh-6rem)]"
|
||||||
title={currentDocumentFlow.title}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
description={currentDocumentFlow.description}
|
>
|
||||||
/>
|
<Stepper
|
||||||
|
currentStep={currentDocumentFlow.stepIndex}
|
||||||
|
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
|
||||||
|
>
|
||||||
|
<AddTitleFormPartial
|
||||||
|
key={recipients.length}
|
||||||
|
documentFlow={documentFlow.title}
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
document={document}
|
||||||
|
onSubmit={onAddTitleFormSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
{step === 'signers' && (
|
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
|
document={document}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
numberOfSteps={Object.keys(documentFlow).length}
|
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'fields' && (
|
|
||||||
<AddFieldsFormPartial
|
<AddFieldsFormPartial
|
||||||
key={fields.length}
|
key={fields.length}
|
||||||
documentFlow={documentFlow.fields}
|
documentFlow={documentFlow.fields}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
numberOfSteps={Object.keys(documentFlow).length}
|
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'subject' && (
|
|
||||||
<AddSubjectFormPartial
|
<AddSubjectFormPartial
|
||||||
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.subject}
|
documentFlow={documentFlow.subject}
|
||||||
document={document}
|
document={document}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
numberOfSteps={Object.keys(documentFlow).length}
|
|
||||||
onSubmit={onAddSubjectFormSubmit}
|
onSubmit={onAddSubjectFormSubmit}
|
||||||
/>
|
/>
|
||||||
)}
|
</Stepper>
|
||||||
</DocumentFlowFormContainer>
|
</DocumentFlowFormContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,4 @@
|
|||||||
import Link from 'next/link';
|
import DocumentPageComponent from './document-page-component';
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
|
||||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
|
||||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
|
||||||
|
|
||||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
|
||||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
export type DocumentPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@@ -20,80 +6,6 @@ export type DocumentPageProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
export default function DocumentPage({ params }: DocumentPageProps) {
|
||||||
const { id } = params;
|
return <DocumentPageComponent params={params} />;
|
||||||
|
|
||||||
const documentId = Number(id);
|
|
||||||
|
|
||||||
if (!documentId || Number.isNaN(documentId)) {
|
|
||||||
redirect('/documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const document = await getDocumentById({
|
|
||||||
id: documentId,
|
|
||||||
userId: user.id,
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
if (!document || !document.documentData) {
|
|
||||||
redirect('/documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { documentData } = document;
|
|
||||||
|
|
||||||
const [recipients, fields] = await Promise.all([
|
|
||||||
await getRecipientsForDocument({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
await getFieldsForDocument({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
|
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
|
||||||
Documents
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
|
||||||
{document.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
|
||||||
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
|
||||||
<div className="text-muted-foreground flex items-center">
|
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
|
||||||
|
|
||||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
|
||||||
</StackAvatarsWithTooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{document.status !== InternalDocumentStatus.COMPLETED && (
|
|
||||||
<EditDocumentForm
|
|
||||||
className="mt-8"
|
|
||||||
document={document}
|
|
||||||
user={user}
|
|
||||||
recipients={recipients}
|
|
||||||
fields={fields}
|
|
||||||
documentData={documentData}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
|
||||||
<div className="mx-auto mt-12 max-w-2xl">
|
|
||||||
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { History } from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
|
||||||
|
|
||||||
|
const FORM_ID = 'resend-email';
|
||||||
|
|
||||||
|
export type ResendDocumentActionItemProps = {
|
||||||
|
document: Document;
|
||||||
|
recipients: Recipient[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ZResendDocumentFormSchema = z.object({
|
||||||
|
recipients: z.array(z.number()).min(1, {
|
||||||
|
message: 'You must select at least one item.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
|
||||||
|
|
||||||
|
export const ResendDocumentActionItem = ({
|
||||||
|
document,
|
||||||
|
recipients,
|
||||||
|
}: ResendDocumentActionItemProps) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const isOwner = document.userId === session?.user?.id;
|
||||||
|
|
||||||
|
const isDisabled =
|
||||||
|
!isOwner ||
|
||||||
|
document.status !== 'PENDING' ||
|
||||||
|
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||||
|
|
||||||
|
const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<TResendDocumentFormSchema>({
|
||||||
|
resolver: zodResolver(ZResendDocumentFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
recipients: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||||
|
try {
|
||||||
|
await resendDocument({ documentId: document.id, recipients });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Document re-sent',
|
||||||
|
description: 'Your document has been re-sent successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'This document could not be re-sent at this time. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
|
||||||
|
<History className="mr-2 h-4 w-4" />
|
||||||
|
Resend
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-sm" hideClose>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="recipients"
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<>
|
||||||
|
{recipients.map((recipient) => (
|
||||||
|
<FormItem
|
||||||
|
key={recipient.id}
|
||||||
|
className="flex flex-row items-center justify-between gap-x-3"
|
||||||
|
>
|
||||||
|
<FormLabel
|
||||||
|
className={cn('my-2 flex items-center gap-2 font-normal', {
|
||||||
|
'opacity-50': !value.includes(recipient.id),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<StackAvatar
|
||||||
|
key={recipient.id}
|
||||||
|
type={getRecipientType(recipient)}
|
||||||
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
|
/>
|
||||||
|
{recipient.email}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
|
||||||
|
checkClassName="text-white"
|
||||||
|
value={recipient.id}
|
||||||
|
checked={value.includes(recipient.id)}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
checked
|
||||||
|
? onChange([...value, recipient.id])
|
||||||
|
: onChange(value.filter((v) => v !== recipient.id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||||
|
Send reminder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,23 +2,30 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Edit, Pencil, Share } from 'lucide-react';
|
import { Download, Edit, Pencil } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
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 { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DataTableActionButtonProps = {
|
export type DataTableActionButtonProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
};
|
};
|
||||||
|
teamUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
export const DataTableActionButton = ({ row, teamUrl }: DataTableActionButtonProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
@@ -33,6 +40,52 @@ 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 documentsPath = formatDocumentsPath(teamUrl);
|
||||||
|
|
||||||
|
const onDownloadClick = async () => {
|
||||||
|
try {
|
||||||
|
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 documentData = document?.documentData;
|
||||||
|
|
||||||
|
if (!documentData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentBytes = await getFile(documentData);
|
||||||
|
|
||||||
|
const blob = new Blob([documentBytes], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = window.document.createElement('a');
|
||||||
|
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
|
||||||
|
|
||||||
|
link.href = window.URL.createObjectURL(blob);
|
||||||
|
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
|
||||||
|
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
window.URL.revokeObjectURL(link.href);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'An error occurred while trying to download file.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isOwner,
|
isOwner,
|
||||||
isRecipient,
|
isRecipient,
|
||||||
@@ -42,31 +95,32 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
isSigned,
|
isSigned,
|
||||||
})
|
})
|
||||||
.with({ isOwner: true, isDraft: true }, () => (
|
.with({ isOwner: true, isDraft: true }, () => (
|
||||||
<Button className="w-24" asChild>
|
<Button className="w-32" asChild>
|
||||||
<Link href={`/documents/${row.id}`}>
|
<Link href={`${documentsPath}/${row.id}`}>
|
||||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||||
<Button className="w-24" asChild>
|
<Button className="w-32" asChild>
|
||||||
<Link href={`/sign/${recipient?.token}`}>
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Sign
|
Sign
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.otherwise(() => (
|
.with({ isPending: true, isSigned: true }, () => (
|
||||||
<DocumentShareButton
|
<Button className="w-32" disabled={true}>
|
||||||
documentId={row.id}
|
<Pencil className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
token={recipient?.token}
|
Sign
|
||||||
trigger={({ loading }) => (
|
</Button>
|
||||||
<Button className="w-24" loading={loading}>
|
))
|
||||||
{!loading && <Share className="-ml-1 mr-2 h-4 w-4" />}
|
.with({ isComplete: true }, () => (
|
||||||
Share
|
<Button className="w-32" onClick={onDownloadClick}>
|
||||||
</Button>
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
)}
|
Download
|
||||||
/>
|
</Button>
|
||||||
));
|
))
|
||||||
|
.otherwise(() => <div></div>);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
History,
|
|
||||||
Loader,
|
Loader,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -19,8 +18,10 @@ import {
|
|||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import {
|
import {
|
||||||
@@ -31,7 +32,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
|
import { ResendDocumentActionItem } from './_action-items/resend-document';
|
||||||
|
import { DeleteDocumentDialog } from './delete-document-dialog';
|
||||||
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
|
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
|
||||||
|
|
||||||
export type DataTableActionDropdownProps = {
|
export type DataTableActionDropdownProps = {
|
||||||
@@ -39,9 +41,10 @@ export type DataTableActionDropdownProps = {
|
|||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
};
|
};
|
||||||
|
teamUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({ row, teamUrl }: DataTableActionDropdownProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@@ -59,7 +62,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
// const isPending = row.status === DocumentStatus.PENDING;
|
// const isPending = row.status === DocumentStatus.PENDING;
|
||||||
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 isDocumentDeletable = isOwner && row.status === DocumentStatus.DRAFT;
|
const isDocumentDeletable = isOwner;
|
||||||
|
|
||||||
|
const documentsPath = formatDocumentsPath(teamUrl);
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
let document: DocumentWithData | null = null;
|
let document: DocumentWithData | null = null;
|
||||||
@@ -87,15 +92,18 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
});
|
});
|
||||||
|
|
||||||
const link = window.document.createElement('a');
|
const link = window.document.createElement('a');
|
||||||
|
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
|
||||||
|
|
||||||
link.href = window.URL.createObjectURL(blob);
|
link.href = window.URL.createObjectURL(blob);
|
||||||
link.download = row.title || 'document.pdf';
|
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
|
||||||
|
|
||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
window.URL.revokeObjectURL(link.href);
|
window.URL.revokeObjectURL(link.href);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
@@ -113,7 +121,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
||||||
<Link href={`/documents/${row.id}`}>
|
<Link href={`${documentsPath}/${row.id}`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
@@ -141,19 +149,16 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
|
|
||||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuItem disabled>
|
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
|
||||||
<History className="mr-2 h-4 w-4" />
|
|
||||||
Resend
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DocumentShareButton
|
<DocumentShareButton
|
||||||
documentId={row.id}
|
documentId={row.id}
|
||||||
token={recipient?.token}
|
token={isOwner ? undefined : recipient?.token}
|
||||||
trigger={({ loading, disabled }) => (
|
trigger={({ loading, disabled }) => (
|
||||||
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
||||||
Share
|
Share Signing Card
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
@@ -161,8 +166,10 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
{isDocumentDeletable && (
|
{isDocumentDeletable && (
|
||||||
<DeleteDraftDocumentDialog
|
<DeleteDocumentDialog
|
||||||
id={row.id}
|
id={row.id}
|
||||||
|
status={row.status}
|
||||||
|
documentTitle={row.title}
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
/>
|
/>
|
||||||
@@ -172,6 +179,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
id={row.id}
|
id={row.id}
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
|
teamUrl={teamUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
import { parseToNumberArray } from '@documenso/lib/utils/params';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||||
|
|
||||||
|
type DataTableSenderFilterProps = {
|
||||||
|
teamId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
|
const senderIds = parseToNumberArray(searchParams?.get('senderIds') ?? '');
|
||||||
|
|
||||||
|
const { data, isInitialLoading } = trpc.team.getTeamMembers.useQuery({
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const comboBoxOptions = (data ?? []).map((member) => ({
|
||||||
|
label: member.user.name ?? member.user.email,
|
||||||
|
value: member.user.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const onChange = (newSenderIds: number[]) => {
|
||||||
|
if (!pathname) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(searchParams?.toString());
|
||||||
|
|
||||||
|
params.set('senderIds', newSenderIds.join(','));
|
||||||
|
|
||||||
|
if (newSenderIds.length === 0) {
|
||||||
|
params.delete('senderIds');
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
emptySelectionPlaceholder={
|
||||||
|
<p className="text-muted-foreground font-normal">
|
||||||
|
<span className="text-muted-foreground/70">Sender:</span> All
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
enableClearAllButton={true}
|
||||||
|
inputPlaceholder="Search"
|
||||||
|
loading={!isMounted || isInitialLoading}
|
||||||
|
options={comboBoxOptions}
|
||||||
|
selectedValues={senderIds}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,8 +6,9 @@ import { Loader } from 'lucide-react';
|
|||||||
import { useSession } from 'next-auth/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 type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
import { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
|
||||||
@@ -26,9 +27,15 @@ export type DocumentsDataTableProps = {
|
|||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
showSenderColumn?: boolean;
|
||||||
|
teamUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
export const DocumentsDataTable = ({
|
||||||
|
results,
|
||||||
|
showSenderColumn,
|
||||||
|
teamUrl,
|
||||||
|
}: DocumentsDataTableProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -60,6 +67,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
header: 'Title',
|
header: 'Title',
|
||||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'sender',
|
||||||
|
header: 'Sender',
|
||||||
|
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: 'Recipient',
|
header: 'Recipient',
|
||||||
accessorKey: 'recipient',
|
accessorKey: 'recipient',
|
||||||
@@ -74,12 +86,14 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) =>
|
||||||
<div className="flex items-center gap-x-4">
|
(!row.original.deletedAt ||
|
||||||
<DataTableActionButton row={row.original} />
|
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||||
<DataTableActionDropdown row={row.original} />
|
<div className="flex items-center gap-x-4">
|
||||||
</div>
|
<DataTableActionButton teamUrl={teamUrl} row={row.original} />
|
||||||
),
|
<DataTableActionDropdown teamUrl={teamUrl} row={row.original} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
data={results.data}
|
data={results.data}
|
||||||
@@ -87,6 +101,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
currentPage={results.currentPage}
|
currentPage={results.currentPage}
|
||||||
totalPages={results.totalPages}
|
totalPages={results.totalPages}
|
||||||
onPaginationChange={onPaginationChange}
|
onPaginationChange={onPaginationChange}
|
||||||
|
columnVisibility={{
|
||||||
|
sender: Boolean(showSenderColumn),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
type DeleteDraftDocumentDialogProps = {
|
||||||
|
id: number;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
status: DocumentStatus;
|
||||||
|
documentTitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeleteDocumentDialog = ({
|
||||||
|
id,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
status,
|
||||||
|
documentTitle,
|
||||||
|
}: DeleteDraftDocumentDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||||
|
|
||||||
|
const { mutateAsync: deleteDocument, isLoading } = trpcReact.document.deleteDocument.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Document deleted',
|
||||||
|
description: `"${documentTitle}" has been successfully deleted`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setInputValue('');
|
||||||
|
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
|
||||||
|
}
|
||||||
|
}, [open, status]);
|
||||||
|
|
||||||
|
const onDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteDocument({ id, status });
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'This document could not be deleted at this time. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setInputValue(event.target.value);
|
||||||
|
setIsDeleteEnabled(event.target.value === 'delete');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
Please note that this action is irreversible. Once confirmed, your document will be
|
||||||
|
permanently deleted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{status !== DocumentStatus.DRAFT && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder="Type 'delete' to confirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={!isDeleteEnabled}
|
||||||
|
variant="destructive"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
|
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
|
||||||
|
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||||
|
import { parseToNumberArray } from '@documenso/lib/utils/params';
|
||||||
|
import type { Team, TeamEmail } from '@documenso/prisma/client';
|
||||||
|
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||||
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
|
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||||
|
import {
|
||||||
|
type PeriodSelectorValue,
|
||||||
|
isPeriodSelectorValue,
|
||||||
|
} from '~/components/(dashboard)/period-selector/types';
|
||||||
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
|
import { DocumentsDataTable } from './data-table';
|
||||||
|
import { DataTableSenderFilter } from './data-table-sender-filter';
|
||||||
|
import { EmptyDocumentState } from './empty-state';
|
||||||
|
import { UploadDocument } from './upload-document';
|
||||||
|
|
||||||
|
export type DocumentsPageComponentProps = {
|
||||||
|
searchParams?: {
|
||||||
|
status?: ExtendedDocumentStatus;
|
||||||
|
period?: PeriodSelectorValue;
|
||||||
|
page?: string;
|
||||||
|
perPage?: string;
|
||||||
|
senderIds?: string;
|
||||||
|
};
|
||||||
|
team?: Team & { teamEmail?: TeamEmail | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DocumentsPageComponent({
|
||||||
|
searchParams = {},
|
||||||
|
team,
|
||||||
|
}: DocumentsPageComponentProps) {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||||
|
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||||
|
const page = Number(searchParams.page) || 1;
|
||||||
|
const perPage = Number(searchParams.perPage) || 20;
|
||||||
|
const documentsPath = team ? `/t/${team.url}/documents` : '/documents';
|
||||||
|
const senderIds = parseToNumberArray(searchParams.senderIds ?? '');
|
||||||
|
|
||||||
|
let teamStatOptions: GetStatsInput['team'] = undefined;
|
||||||
|
|
||||||
|
if (team) {
|
||||||
|
teamStatOptions = {
|
||||||
|
teamId: team.id,
|
||||||
|
teamEmail: team.teamEmail?.email,
|
||||||
|
senderIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await getStats({
|
||||||
|
user,
|
||||||
|
team: teamStatOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await findDocuments({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
status,
|
||||||
|
orderBy: {
|
||||||
|
column: 'createdAt',
|
||||||
|
direction: 'desc',
|
||||||
|
},
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
period,
|
||||||
|
senderIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTabHref = (value: typeof status) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
|
params.set('status', value);
|
||||||
|
|
||||||
|
if (params.has('page')) {
|
||||||
|
params.delete('page');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${documentsPath}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<UploadDocument team={team ? { id: team.id, url: team.url } : undefined} />
|
||||||
|
|
||||||
|
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
{team && (
|
||||||
|
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||||
|
<AvatarFallback className="text-xs text-gray-400">
|
||||||
|
{team.name.slice(0, 1)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-semibold">Documents</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||||
|
<Tabs defaultValue={status} className="overflow-x-auto">
|
||||||
|
<TabsList>
|
||||||
|
{[
|
||||||
|
ExtendedDocumentStatus.INBOX,
|
||||||
|
ExtendedDocumentStatus.PENDING,
|
||||||
|
ExtendedDocumentStatus.COMPLETED,
|
||||||
|
ExtendedDocumentStatus.DRAFT,
|
||||||
|
ExtendedDocumentStatus.ALL,
|
||||||
|
].map((value) => (
|
||||||
|
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
|
||||||
|
<Link href={getTabHref(value)} scroll={false}>
|
||||||
|
<DocumentStatus status={value} />
|
||||||
|
|
||||||
|
{value !== ExtendedDocumentStatus.ALL && (
|
||||||
|
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||||
|
{Math.min(stats[value], 99)}
|
||||||
|
{stats[value] > 99 && '+'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{team && <DataTableSenderFilter teamId={team.id} />}
|
||||||
|
|
||||||
|
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||||
|
<PeriodSelector />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
{results.count > 0 && (
|
||||||
|
<DocumentsDataTable
|
||||||
|
results={results}
|
||||||
|
showSenderColumn={team !== undefined}
|
||||||
|
teamUrl={team?.url}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{results.count === 0 && <EmptyDocumentState status={status} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -16,31 +17,36 @@ type DuplicateDocumentDialogProps = {
|
|||||||
id: number;
|
id: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
teamUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DuplicateDocumentDialog = ({
|
export const DuplicateDocumentDialog = ({
|
||||||
id,
|
id,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
teamUrl,
|
||||||
}: DuplicateDocumentDialogProps) => {
|
}: DuplicateDocumentDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
||||||
id,
|
id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentData = data?.documentData
|
const documentData = document?.documentData
|
||||||
? {
|
? {
|
||||||
...data.documentData,
|
...document.documentData,
|
||||||
data: data.documentData.initialData,
|
data: document.documentData.initialData,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const documentsPath = formatDocumentsPath(teamUrl);
|
||||||
|
|
||||||
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||||
trpcReact.document.duplicateDocument.useMutation({
|
trpcReact.document.duplicateDocument.useMutation({
|
||||||
onSuccess: (newId) => {
|
onSuccess: (newId) => {
|
||||||
router.push(`/documents/${newId}`);
|
router.push(`${documentsPath}/${newId}`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document Duplicated',
|
title: 'Document Duplicated',
|
||||||
description: 'Your document has been successfully duplicated.',
|
description: 'Your document has been successfully duplicated.',
|
||||||
@@ -78,7 +84,7 @@ export const DuplicateDocumentDialog = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll ">
|
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll ">
|
||||||
<LazyPDFViewer key={data?.id} documentData={documentData} />
|
<LazyPDFViewer key={document?.id} documentData={documentData} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,111 +1,10 @@
|
|||||||
import Link from 'next/link';
|
import type { DocumentsPageComponentProps } from './documents-page-component';
|
||||||
|
import DocumentsPageComponent from './documents-page-component';
|
||||||
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 { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
|
|
||||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
|
||||||
import {
|
|
||||||
PeriodSelectorValue,
|
|
||||||
isPeriodSelectorValue,
|
|
||||||
} from '~/components/(dashboard)/period-selector/types';
|
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
|
||||||
|
|
||||||
import { DocumentsDataTable } from './data-table';
|
|
||||||
import { EmptyDocumentState } from './empty-state';
|
|
||||||
import { UploadDocument } from './upload-document';
|
|
||||||
|
|
||||||
export type DocumentsPageProps = {
|
export type DocumentsPageProps = {
|
||||||
searchParams?: {
|
searchParams?: DocumentsPageComponentProps['searchParams'];
|
||||||
status?: ExtendedDocumentStatus;
|
|
||||||
period?: PeriodSelectorValue;
|
|
||||||
page?: string;
|
|
||||||
perPage?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
return <DocumentsPageComponent searchParams={searchParams} />;
|
||||||
|
|
||||||
const stats = await getStats({
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
|
||||||
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
|
||||||
const page = Number(searchParams.page) || 1;
|
|
||||||
const perPage = Number(searchParams.perPage) || 20;
|
|
||||||
|
|
||||||
const results = await findDocuments({
|
|
||||||
userId: user.id,
|
|
||||||
status,
|
|
||||||
orderBy: {
|
|
||||||
column: 'createdAt',
|
|
||||||
direction: 'desc',
|
|
||||||
},
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
period,
|
|
||||||
});
|
|
||||||
|
|
||||||
const getTabHref = (value: typeof status) => {
|
|
||||||
const params = new URLSearchParams(searchParams);
|
|
||||||
|
|
||||||
params.set('status', value);
|
|
||||||
|
|
||||||
if (params.has('page')) {
|
|
||||||
params.delete('page');
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/documents?${params.toString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
<UploadDocument />
|
|
||||||
|
|
||||||
<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="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
|
||||||
<Tabs defaultValue={status} className="overflow-x-auto">
|
|
||||||
<TabsList>
|
|
||||||
{[
|
|
||||||
ExtendedDocumentStatus.INBOX,
|
|
||||||
ExtendedDocumentStatus.PENDING,
|
|
||||||
ExtendedDocumentStatus.COMPLETED,
|
|
||||||
ExtendedDocumentStatus.DRAFT,
|
|
||||||
ExtendedDocumentStatus.ALL,
|
|
||||||
].map((value) => (
|
|
||||||
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
|
|
||||||
<Link href={getTabHref(value)} scroll={false}>
|
|
||||||
<DocumentStatus status={value} />
|
|
||||||
|
|
||||||
{value !== ExtendedDocumentStatus.ALL && (
|
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
|
||||||
{Math.min(stats[value], 99)}
|
|
||||||
{stats[value] > 99 && '+'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
|
||||||
<PeriodSelector />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
{results.count > 0 && <DocumentsDataTable results={results} />}
|
|
||||||
{results.count === 0 && <EmptyDocumentState status={status} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
@@ -18,10 +20,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
export type UploadDocumentProps = {
|
export type UploadDocumentProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
team?: {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -45,6 +53,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
const { id } = await createDocument({
|
const { id } = await createDocument({
|
||||||
title: file.name,
|
title: file.name,
|
||||||
documentDataId,
|
documentDataId,
|
||||||
|
teamId: team?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -53,7 +62,13 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(`/documents/${id}`);
|
analytics.capture('App: Document Uploaded', {
|
||||||
|
userId: session?.user.id,
|
||||||
|
documentId: id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(team?.id !== undefined ? `/t/${team.url}/documents/${id}` : `/documents/${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
@@ -79,16 +94,18 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
<DocumentDropzone
|
<DocumentDropzone
|
||||||
className="min-h-[40vh]"
|
className="min-h-[40vh]"
|
||||||
disabled={remaining.documents === 0}
|
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute -bottom-6 right-0">
|
<div className="absolute -bottom-6 right-0">
|
||||||
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
|
{team?.id === undefined &&
|
||||||
<p className="text-muted-foreground/60 text-xs">
|
remaining.documents > 0 &&
|
||||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
Number.isFinite(remaining.documents) && (
|
||||||
</p>
|
<p className="text-muted-foreground/60 text-xs">
|
||||||
)}
|
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
@@ -97,7 +114,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{remaining.documents === 0 && (
|
{team?.id === undefined && remaining.documents === 0 && (
|
||||||
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { getServerSession } from 'next-auth';
|
|||||||
|
|
||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||||
|
|
||||||
import { Header } from '~/components/(dashboard)/layout/header';
|
import { Header } from '~/components/(dashboard)/layout/header';
|
||||||
|
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||||
import { NextAuthProvider } from '~/providers/next-auth';
|
import { NextAuthProvider } from '~/providers/next-auth';
|
||||||
|
|
||||||
@@ -25,12 +27,17 @@ export default async function AuthenticatedDashboardLayout({
|
|||||||
redirect('/signin');
|
redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const [{ user }, teams] = await Promise.all([
|
||||||
|
getRequiredServerComponentSession(),
|
||||||
|
getTeams({ userId: session.user.id }),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextAuthProvider session={session}>
|
<NextAuthProvider session={session}>
|
||||||
<LimitsProvider>
|
<LimitsProvider>
|
||||||
<Header user={user} />
|
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||||
|
|
||||||
|
<Header user={user} teams={teams} />
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { createBillingPortal } from './create-billing-portal.action';
|
import { createBillingPortal } from './create-billing-portal.action';
|
||||||
|
|
||||||
export const BillingPortalButton = () => {
|
export type BillingPortalButtonProps = {
|
||||||
|
buttonProps?: React.ComponentProps<typeof Button>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
||||||
@@ -48,7 +52,11 @@ export const BillingPortalButton = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
|
<Button
|
||||||
|
{...buttonProps}
|
||||||
|
onClick={async () => handleFetchPortalUrl()}
|
||||||
|
loading={isFetchingPortalUrl}
|
||||||
|
>
|
||||||
Manage Subscription
|
Manage Subscription
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +1,13 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import {
|
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
getStripeCustomerByEmail,
|
|
||||||
getStripeCustomerById,
|
|
||||||
} from '@documenso/ee/server-only/stripe/get-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-component-session';
|
||||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
|
||||||
|
|
||||||
export const createBillingPortal = async () => {
|
export const createBillingPortal = async () => {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
const { stripeCustomer } = await getStripeCustomerByUser(user);
|
||||||
|
|
||||||
let stripeCustomer: Stripe.Customer | null = null;
|
|
||||||
|
|
||||||
// Find the Stripe customer for the current user subscription.
|
|
||||||
if (existingSubscription) {
|
|
||||||
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
|
|
||||||
|
|
||||||
if (!stripeCustomer) {
|
|
||||||
throw new Error('Missing Stripe customer for subscription');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to check if a Stripe customer already exists for the current user email.
|
|
||||||
if (!stripeCustomer) {
|
|
||||||
stripeCustomer = await getStripeCustomerByEmail(user.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a Stripe customer if it does not exist for the current user.
|
|
||||||
if (!stripeCustomer) {
|
|
||||||
stripeCustomer = await stripe.customers.create({
|
|
||||||
name: user.name ?? undefined,
|
|
||||||
email: user.email,
|
|
||||||
metadata: {
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return getPortalSession({
|
return getPortalSession({
|
||||||
customerId: stripeCustomer.id,
|
customerId: stripeCustomer.id,
|
||||||
|
|||||||
@@ -1,55 +1,36 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
|
||||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||||
import {
|
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
getStripeCustomerByEmail,
|
|
||||||
getStripeCustomerById,
|
|
||||||
} from '@documenso/ee/server-only/stripe/get-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-component-session';
|
||||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
|
||||||
|
|
||||||
export type CreateCheckoutOptions = {
|
export type CreateCheckoutOptions = {
|
||||||
priceId: string;
|
priceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
|
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const session = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
const { user, stripeCustomer } = await getStripeCustomerByUser(session.user);
|
||||||
|
|
||||||
let stripeCustomer: Stripe.Customer | null = null;
|
const existingSubscriptions = await getSubscriptionsByUserId({ userId: user.id });
|
||||||
|
|
||||||
// Find the Stripe customer for the current user subscription.
|
const foundSubscription = existingSubscriptions.find(
|
||||||
if (existingSubscription) {
|
(subscription) =>
|
||||||
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
|
subscription.priceId === priceId &&
|
||||||
|
subscription.periodEnd &&
|
||||||
if (!stripeCustomer) {
|
subscription.periodEnd >= new Date(),
|
||||||
throw new Error('Missing Stripe customer for subscription');
|
);
|
||||||
}
|
|
||||||
|
|
||||||
|
if (foundSubscription) {
|
||||||
return getPortalSession({
|
return getPortalSession({
|
||||||
customerId: stripeCustomer.id,
|
customerId: stripeCustomer.id,
|
||||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to check if a Stripe customer already exists for the current user email.
|
|
||||||
if (!stripeCustomer) {
|
|
||||||
stripeCustomer = await getStripeCustomerByEmail(user.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a Stripe customer if it does not exist for the current user.
|
|
||||||
if (!stripeCustomer) {
|
|
||||||
await createCustomer({
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
|
|
||||||
stripeCustomer = await getStripeCustomerByEmail(user.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getCheckoutSession({
|
return getCheckoutSession({
|
||||||
customerId: stripeCustomer.id,
|
customerId: stripeCustomer.id,
|
||||||
priceId,
|
priceId,
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
|
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
|
||||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||||
|
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
@@ -15,7 +18,7 @@ import { BillingPlans } from './billing-plans';
|
|||||||
import { BillingPortalButton } from './billing-portal-button';
|
import { BillingPortalButton } from './billing-portal-button';
|
||||||
|
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
let { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
||||||
|
|
||||||
@@ -24,24 +27,40 @@ export default async function BillingSettingsPage() {
|
|||||||
redirect('/settings/profile');
|
redirect('/settings/profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [subscription, prices] = await Promise.all([
|
if (!user.customerId) {
|
||||||
getSubscriptionByUserId({ userId: user.id }),
|
user = await getStripeCustomerByUser(user).then((result) => result.user);
|
||||||
getPricesByInterval(),
|
}
|
||||||
|
|
||||||
|
const [subscriptions, prices, individualPrices] = await Promise.all([
|
||||||
|
getSubscriptionsByUserId({ userId: user.id }),
|
||||||
|
getPricesByInterval({ type: 'individual' }),
|
||||||
|
getPricesByType('individual'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const individualPriceIds = individualPrices.map(({ id }) => id);
|
||||||
|
|
||||||
let subscriptionProduct: Stripe.Product | null = null;
|
let subscriptionProduct: Stripe.Product | null = null;
|
||||||
|
|
||||||
|
const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||||
|
individualPriceIds.includes(priceId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscription =
|
||||||
|
individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||||
|
individualUserSubscriptions[0];
|
||||||
|
|
||||||
if (subscription?.priceId) {
|
if (subscription?.priceId) {
|
||||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||||
() => null,
|
() => null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
|
const isMissingOrInactiveOrFreePlan =
|
||||||
|
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Billing</h3>
|
<h3 className="text-2xl font-semibold">Billing</h3>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-2 text-sm">
|
<div className="text-muted-foreground mt-2 text-sm">
|
||||||
{isMissingOrInactiveOrFreePlan && (
|
{isMissingOrInactiveOrFreePlan && (
|
||||||
|
|||||||
@@ -1,19 +1,5 @@
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { PasswordForm } from '~/components/forms/password';
|
export default function PasswordSettingsPage() {
|
||||||
|
redirect('/settings/security');
|
||||||
export default async function PasswordSettingsPage() {
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium">Password</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
<PasswordForm user={user} className="max-w-xl" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
import { ProfileForm } from '~/components/forms/profile';
|
import { ProfileForm } from '~/components/forms/profile';
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ export default async function ProfileSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Profile</h3>
|
<h3 className="text-2xl font-semibold">Profile</h3>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">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>
|
||||||
|
|
||||||
|
|||||||
46
apps/web/src/app/(dashboard)/settings/security/page.tsx
Normal file
46
apps/web/src/app/(dashboard)/settings/security/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
|
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||||
|
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||||
|
import { PasswordForm } from '~/components/forms/password';
|
||||||
|
|
||||||
|
export default async function SecuritySettingsPage() {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-semibold">Security</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Here you can manage your password and security settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
<PasswordForm user={user} className="max-w-xl" />
|
||||||
|
|
||||||
|
<hr className="mb-4 mt-8" />
|
||||||
|
|
||||||
|
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Add and manage your two factor security settings to add an extra layer of security to your
|
||||||
|
account!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 max-w-xl">
|
||||||
|
<h5 className="font-medium">Two-factor methods</h5>
|
||||||
|
|
||||||
|
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.twoFactorEnabled && (
|
||||||
|
<div className="mt-4 max-w-xl">
|
||||||
|
<h5 className="font-medium">Recovery methods</h5>
|
||||||
|
|
||||||
|
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type AcceptTeamInvitationButtonProps = {
|
||||||
|
teamId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: acceptTeamInvitation,
|
||||||
|
isLoading,
|
||||||
|
isSuccess,
|
||||||
|
} = trpc.team.acceptTeamInvitation.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Accepted team invitation',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
description: 'Unable to join this team at this time.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={async () => acceptTeamInvitation({ teamId })}
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={isLoading || isSuccess}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
46
apps/web/src/app/(dashboard)/settings/teams/page.tsx
Normal file
46
apps/web/src/app/(dashboard)/settings/teams/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
|
||||||
|
import SettingsHeader from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
import CreateTeamDialog from '~/components/(teams)/dialogs/create-team-dialog';
|
||||||
|
import UserTeamsPageDataTable from '~/components/(teams)/tables/user-teams-page-data-table';
|
||||||
|
|
||||||
|
import TeamEmailUsage from './team-email-usage';
|
||||||
|
import { TeamInvitations } from './team-invitations';
|
||||||
|
|
||||||
|
export default function TeamsSettingsPage() {
|
||||||
|
const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader title="Teams" subtitle="Manage all teams you are currently associated with.">
|
||||||
|
<CreateTeamDialog />
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<UserTeamsPageDataTable />
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{teamEmail && (
|
||||||
|
<motion.section
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TeamEmailUsage teamEmail={teamEmail} />
|
||||||
|
</motion.section>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<TeamInvitations />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx
Normal file
103
apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { TeamEmail } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type TeamEmailUsageProps = {
|
||||||
|
teamEmail: TeamEmail & { team: { name: string; url: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TeamEmailUsage({ teamEmail }: TeamEmailUsageProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
|
||||||
|
trpc.team.deleteTeamEmail.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'You have successfully revoked access.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting to revoke access. Please try again or contact support.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8 flex flex-row items-center justify-between rounded-lg bg-gray-50/70 p-6">
|
||||||
|
<div className="text-sm">
|
||||||
|
<h3 className="text-base font-medium">Team email</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Your email is currently being used by team{' '}
|
||||||
|
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}
|
||||||
|
).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-1">They have permission on your behalf to:</p>
|
||||||
|
|
||||||
|
<ul className="text-muted-foreground mt-0.5 list-inside list-disc">
|
||||||
|
<li>Display your name and email in documents</li>
|
||||||
|
<li>View all documents sent to your account</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamEmail && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="destructive">Revoke access</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
You are about to revoke access for team{' '}
|
||||||
|
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}) to
|
||||||
|
use your email.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<fieldset disabled={isDeletingTeamEmail}>
|
||||||
|
<DialogFooter className="space-x-4">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
loading={isDeletingTeamEmail}
|
||||||
|
onClick={async () => deleteTeamEmail({ teamId: teamEmail.teamId })}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { BellIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
|
||||||
|
import { AcceptTeamInvitationButton } from './accept-team-invitation-button';
|
||||||
|
|
||||||
|
export const TeamInvitations = () => {
|
||||||
|
const { data, isInitialLoading } = trpc.team.getTeamInvitations.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{data && data.length > 0 && !isInitialLoading && (
|
||||||
|
<motion.div
|
||||||
|
className="mt-8 flex flex-row items-center justify-between rounded-md bg-blue-50 p-6"
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Todo: Teams - Extract into `Alerts` component? */}
|
||||||
|
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
|
||||||
|
|
||||||
|
<div className="text-sm text-blue-700">
|
||||||
|
You have <strong>{data.length}</strong> pending team invitation
|
||||||
|
{data.length > 1 ? 's' : ''}.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
|
||||||
|
View invites
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Pending invitations</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
|
||||||
|
{data.map((invitation) => (
|
||||||
|
<li key={invitation.teamId}>
|
||||||
|
<AvatarWithText
|
||||||
|
className="w-full max-w-none py-4"
|
||||||
|
avatarFallback={invitation.team.name.slice(0, 1)}
|
||||||
|
primaryText={
|
||||||
|
<span className="text-foreground/80 font-semibold">
|
||||||
|
{invitation.team.name}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
secondaryText={formatTeamUrl(invitation.team.url)}
|
||||||
|
rightSideComponent={
|
||||||
|
<div className="ml-auto">
|
||||||
|
<AcceptTeamInvitationButton teamId={invitation.team.id} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
156
apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx
Normal file
156
apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import {
|
||||||
|
DocumentFlowFormContainer,
|
||||||
|
DocumentFlowFormContainerHeader,
|
||||||
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
|
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
|
||||||
|
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
||||||
|
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
|
||||||
|
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type EditTemplateFormProps = {
|
||||||
|
className?: string;
|
||||||
|
user: User;
|
||||||
|
template: Template;
|
||||||
|
recipients: Recipient[];
|
||||||
|
fields: Field[];
|
||||||
|
documentData: DocumentData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditTemplateStep = 'signers' | 'fields';
|
||||||
|
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
|
||||||
|
|
||||||
|
export const EditTemplateForm = ({
|
||||||
|
className,
|
||||||
|
template,
|
||||||
|
recipients,
|
||||||
|
fields,
|
||||||
|
user: _user,
|
||||||
|
documentData,
|
||||||
|
}: EditTemplateFormProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<EditTemplateStep>('signers');
|
||||||
|
|
||||||
|
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
|
||||||
|
signers: {
|
||||||
|
title: 'Add Placeholders',
|
||||||
|
description: 'Add all relevant placeholders for each recipient.',
|
||||||
|
stepIndex: 1,
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
title: 'Add Fields',
|
||||||
|
description: 'Add all relevant fields for each recipient.',
|
||||||
|
stepIndex: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
|
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
|
||||||
|
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
|
||||||
|
|
||||||
|
const onAddTemplatePlaceholderFormSubmit = async (
|
||||||
|
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await addTemplateSigners({
|
||||||
|
templateId: template.id,
|
||||||
|
signers: data.signers,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
setStep('fields');
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while adding signers.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
|
||||||
|
try {
|
||||||
|
await addTemplateFields({
|
||||||
|
templateId: template.id,
|
||||||
|
fields: data.fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Template saved',
|
||||||
|
description: 'Your templates has been saved successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push('/templates');
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while adding signers.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||||
|
<Card
|
||||||
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
|
gradient
|
||||||
|
>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
|
<DocumentFlowFormContainer
|
||||||
|
className="lg:h-[calc(100vh-6rem)]"
|
||||||
|
onSubmit={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<DocumentFlowFormContainerHeader
|
||||||
|
title={currentDocumentFlow.title}
|
||||||
|
description={currentDocumentFlow.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stepper
|
||||||
|
currentStep={currentDocumentFlow.stepIndex}
|
||||||
|
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
|
||||||
|
>
|
||||||
|
<AddTemplatePlaceholderRecipientsFormPartial
|
||||||
|
key={recipients.length}
|
||||||
|
documentFlow={documentFlow.signers}
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddTemplateFieldsFormPartial
|
||||||
|
key={fields.length}
|
||||||
|
documentFlow={documentFlow.fields}
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
|
/>
|
||||||
|
</Stepper>
|
||||||
|
</DocumentFlowFormContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
81
apps/web/src/app/(dashboard)/templates/[id]/page.tsx
Normal file
81
apps/web/src/app/(dashboard)/templates/[id]/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
||||||
|
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
||||||
|
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||||
|
|
||||||
|
import { TemplateType } from '~/components/formatter/template-type';
|
||||||
|
|
||||||
|
import { EditTemplateForm } from './edit-template';
|
||||||
|
|
||||||
|
export type TemplatePageProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const templateId = Number(id);
|
||||||
|
|
||||||
|
if (!templateId || Number.isNaN(templateId)) {
|
||||||
|
redirect('/documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const template = await getTemplateById({
|
||||||
|
id: templateId,
|
||||||
|
userId: user.id,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!template || !template.templateDocumentData) {
|
||||||
|
redirect('/documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { templateDocumentData } = template;
|
||||||
|
|
||||||
|
const [templateRecipients, templateFields] = await Promise.all([
|
||||||
|
getRecipientsForTemplate({
|
||||||
|
templateId,
|
||||||
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
getFieldsForTemplate({
|
||||||
|
templateId,
|
||||||
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||||
|
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
Templates
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||||
|
{template.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditTemplateForm
|
||||||
|
className="mt-8"
|
||||||
|
template={template}
|
||||||
|
user={user}
|
||||||
|
recipients={templateRecipients}
|
||||||
|
fields={templateFields}
|
||||||
|
documentData={templateDocumentData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
|
import type { Template } from '@documenso/prisma/client';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
import { DeleteTemplateDialog } from './delete-template-dialog';
|
||||||
|
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
||||||
|
|
||||||
|
export type DataTableActionDropdownProps = {
|
||||||
|
row: Template;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = row.userId === session.user.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled={!isOwner} asChild>
|
||||||
|
<Link href={`/templates/${row.id}`}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{/* <DropdownMenuItem disabled={!isOwner} onClick={async () => onDuplicateButtonClick(row.id)}> */}
|
||||||
|
<DropdownMenuItem disabled={!isOwner} onClick={() => setDuplicateDialogOpen(true)}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled={!isOwner} onClick={() => setDeleteDialogOpen(true)}>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
|
||||||
|
<DuplicateTemplateDialog
|
||||||
|
id={row.id}
|
||||||
|
open={isDuplicateDialogOpen}
|
||||||
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteTemplateDialog
|
||||||
|
id={row.id}
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
/>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
138
apps/web/src/app/(dashboard)/templates/data-table-templates.tsx
Normal file
138
apps/web/src/app/(dashboard)/templates/data-table-templates.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Loader, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import type { Template } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
import { TemplateType } from '~/components/formatter/template-type';
|
||||||
|
|
||||||
|
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||||
|
import { DataTableTitle } from './data-table-title';
|
||||||
|
|
||||||
|
type TemplatesDataTableProps = {
|
||||||
|
templates: Template[];
|
||||||
|
perPage: number;
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplatesDataTable = ({
|
||||||
|
templates,
|
||||||
|
perPage,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
}: TemplatesDataTableProps) => {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({});
|
||||||
|
|
||||||
|
const { mutateAsync: createDocumentFromTemplate } =
|
||||||
|
trpc.template.createDocumentFromTemplate.useMutation();
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
startTransition(() => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUseButtonClick = async (templateId: number) => {
|
||||||
|
try {
|
||||||
|
const { id } = await createDocumentFromTemplate({
|
||||||
|
templateId,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Document created',
|
||||||
|
description: 'Your document has been created from the template successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`/documents/${id}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while creating document from template.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Created',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Title',
|
||||||
|
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Type',
|
||||||
|
accessorKey: 'type',
|
||||||
|
cell: ({ row }) => <TemplateType type={row.original.type} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Actions',
|
||||||
|
accessorKey: 'actions',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isRowLoading = loadingStates[row.original.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<Button
|
||||||
|
disabled={isRowLoading}
|
||||||
|
loading={isRowLoading}
|
||||||
|
onClick={async () => {
|
||||||
|
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
|
||||||
|
await onUseButtonClick(row.original.id);
|
||||||
|
setLoadingStates((prev) => ({ ...prev, [row.original.id]: false }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
|
||||||
|
Use Template
|
||||||
|
</Button>
|
||||||
|
<DataTableActionDropdown row={row.original} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={templates}
|
||||||
|
perPage={perPage}
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||||
|
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user