Compare commits
125 Commits
chore/code
...
feat/chat-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a03e74d660 | ||
|
|
1c34eddd10 | ||
|
|
1fbf6ed4ba | ||
|
|
1d6f7f9e37 | ||
|
|
e3c3ec7825 | ||
|
|
08d176c803 | ||
|
|
b952ed9035 | ||
|
|
43062dda12 | ||
|
|
1b53ff9c2d | ||
|
|
acd3e6d613 | ||
|
|
e33b02df56 | ||
|
|
2c6849ca76 | ||
|
|
9434f9e2e4 | ||
|
|
f6daef7333 | ||
|
|
c3df8d4c2a | ||
|
|
4b09693862 | ||
|
|
8d2e50d1fe | ||
|
|
bfc749f30b | ||
|
|
e0d4255700 | ||
|
|
6ba4ff1c17 | ||
|
|
652af26754 | ||
|
|
093488a67c | ||
|
|
0d026f3476 | ||
|
|
3e89ec1afc | ||
|
|
df0d18fc81 | ||
|
|
dd25c355ff | ||
|
|
6f851833b2 | ||
|
|
442b089d7f | ||
|
|
1c58b21383 | ||
|
|
a6e13faf7b | ||
|
|
ede9eb052d | ||
|
|
fab006078c | ||
|
|
4d5275f915 | ||
|
|
901e83af58 | ||
|
|
e1bee1591f | ||
|
|
a354c23231 | ||
|
|
f728dd13c5 | ||
|
|
7927b87259 | ||
|
|
55301a9d53 | ||
|
|
c0dd57a4d2 | ||
|
|
cc80773402 | ||
|
|
c803d2c4ba | ||
|
|
eb5f5f7a90 | ||
|
|
2ea5ff2c94 | ||
|
|
bc9a6fa50a | ||
|
|
e02ab7d256 | ||
|
|
01e6367b72 | ||
|
|
565602f8e1 | ||
|
|
9e0d281883 | ||
|
|
67629dd735 | ||
|
|
2a89278c7b | ||
|
|
8f4ba6eb8a | ||
|
|
8dfcfb99e0 | ||
|
|
1299aa51ee | ||
|
|
e0271cace3 | ||
|
|
a11440a7f3 | ||
|
|
cc8c4b8297 | ||
|
|
a287aab4f4 | ||
|
|
4c518df60d | ||
|
|
d4ae733e9e | ||
|
|
b5ed703553 | ||
|
|
f49880125a | ||
|
|
8380c357d9 | ||
|
|
4e010c5624 | ||
|
|
f53cdbace9 | ||
|
|
b4d04e2ce9 | ||
|
|
2470aeee1f | ||
|
|
fd07b47325 | ||
|
|
9257a05831 | ||
|
|
1faa6f2944 | ||
|
|
5584bbe9ca | ||
|
|
cc65537ea3 | ||
|
|
5f14f87406 | ||
|
|
04a80b7c03 | ||
|
|
2b44e54d99 | ||
|
|
c71a89d1b7 | ||
|
|
e2abfd2312 | ||
|
|
49d55227e8 | ||
|
|
0dadec3b8d | ||
|
|
e2d8591d66 | ||
|
|
aecc703317 | ||
|
|
2422c3e7be | ||
|
|
4e1994a0c8 | ||
|
|
a3dce67117 | ||
|
|
64dcd451e9 | ||
|
|
a85523ecfc | ||
|
|
85b32bb15b | ||
|
|
742ad86b10 | ||
|
|
39ff11a59d | ||
|
|
4f5976479a | ||
|
|
eac7aa84b0 | ||
|
|
bd941202c8 | ||
|
|
b854f0eedc | ||
|
|
1814bd4167 | ||
|
|
b6f9d70fec | ||
|
|
7c54913bf5 | ||
|
|
e8d5044ac5 | ||
|
|
ddf097ede3 | ||
|
|
1bad85e1d6 | ||
|
|
68458b50d2 | ||
|
|
e00f28cf87 | ||
|
|
4cc34ec50a | ||
|
|
693249916d | ||
|
|
381a248543 | ||
|
|
f637381198 | ||
|
|
071335cc66 | ||
|
|
4d4b011146 | ||
|
|
d10713b477 | ||
|
|
2efaabd2c3 | ||
|
|
7bc1e9dcc8 | ||
|
|
8848df701c | ||
|
|
2e800d0eed | ||
|
|
70ecc9a4a8 | ||
|
|
97dfacd133 | ||
|
|
b5fc6e1aaf | ||
|
|
87f70fa290 | ||
|
|
c2cda0f06e | ||
|
|
f1bc772985 | ||
|
|
07bf780c3e | ||
|
|
775de16d0a | ||
|
|
4d485940ea | ||
|
|
cbe118b74f | ||
|
|
de9116e9b2 | ||
|
|
027a588604 | ||
|
|
773566f193 |
@@ -9,10 +9,5 @@ npm install
|
|||||||
# Copy the env file
|
# Copy the env file
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# Source the env file, export the variables
|
|
||||||
set -a
|
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
|
|
||||||
# Run the migrations
|
# Run the migrations
|
||||||
npm run -w @documenso/prisma prisma:migrate-dev
|
npm run prisma:migrate-dev
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
|
|||||||
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
||||||
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||||
|
|
||||||
|
# [[E2E Tests]]
|
||||||
|
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||||
|
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||||
|
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
|
||||||
|
|
||||||
# [[STORAGE]]
|
# [[STORAGE]]
|
||||||
# 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"
|
||||||
@@ -68,6 +73,7 @@ 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=
|
||||||
|
|
||||||
# [[FEATURES]]
|
# [[FEATURES]]
|
||||||
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
||||||
|
|||||||
50
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
50
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Create a bug report to help us improve
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--- Please provide a general summary of the issue in the Title above -->
|
||||||
|
|
||||||
|
## Issue Description
|
||||||
|
|
||||||
|
<!--- Please provide a clear and concise description of the problem. -->
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
|
||||||
|
<!--- Please provide step-by-step instructions to reproduce the issue. -->
|
||||||
|
<!--- Include code snippets, error messages, and any other relevant information. -->
|
||||||
|
|
||||||
|
1. Step 1
|
||||||
|
2. Step 2
|
||||||
|
3. ...
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
<!--- Describe what you expected to happen. -->
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
<!--- Describe what is currently happening. -->
|
||||||
|
|
||||||
|
## Screenshots (optional)
|
||||||
|
|
||||||
|
<!--- If applicable, add screenshots to help explain the issue. -->
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
<!--- Please provide information about your environment, such as operating system, browser, version, etc. -->
|
||||||
|
|
||||||
|
- OS: [e.g., Windows 10]
|
||||||
|
- Browser: [e.g., Chrome, Firefox]
|
||||||
|
- Version: [e.g., 2.0.1]
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
<!--- Please check the boxes that apply to this issue report. -->
|
||||||
|
<!--- You can add or remove items as needed. -->
|
||||||
|
|
||||||
|
- [ ] I have searched the existing issues to make sure this is not a duplicate.
|
||||||
|
- [ ] I have provided steps to reproduce the issue.
|
||||||
|
- [ ] I have included relevant environment information.
|
||||||
|
- [ ] I have included any relevant screenshots.
|
||||||
|
- [ ] I understand that this is a voluntary contribution and that there is no guarantee of resolution.
|
||||||
41
.github/ISSUE_TEMPLATE/feature-request.md
vendored
Normal file
41
.github/ISSUE_TEMPLATE/feature-request.md
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Suggest a new idea or enhancement for this project
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--- Please provide a clear and concise title for your feature request -->
|
||||||
|
|
||||||
|
## Feature Description
|
||||||
|
|
||||||
|
<!--- Describe the feature you are requesting in detail. -->
|
||||||
|
<!--- Explain what problem it solves or what value it adds to the project. -->
|
||||||
|
|
||||||
|
## Use Case
|
||||||
|
|
||||||
|
<!--- Provide a scenario or use case where this feature would be beneficial. -->
|
||||||
|
<!--- Explain how users would interact with this feature and why it's important. -->
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
<!--- If you have an idea of how this feature could be implemented, describe it here. -->
|
||||||
|
<!--- Include any technical details, UI/UX considerations, or design suggestions. -->
|
||||||
|
|
||||||
|
## Alternatives (optional)
|
||||||
|
|
||||||
|
<!--- Are there any alternative ways to achieve the same goal? -->
|
||||||
|
<!--- Describe other approaches that could be considered if this feature is not implemented. -->
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
|
||||||
|
<!--- Add any additional context or information that might be relevant to the feature request. -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
<!--- Please check the boxes that apply to this feature request. -->
|
||||||
|
<!--- You can add or remove items as needed. -->
|
||||||
|
|
||||||
|
- [ ] I have searched the existing feature requests to make sure this is not a duplicate.
|
||||||
|
- [ ] I have provided a detailed description of the requested feature.
|
||||||
|
- [ ] I have explained the use case or scenario for this feature.
|
||||||
|
- [ ] I have included any relevant technical details or design suggestions.
|
||||||
|
- [ ] I understand that this is a suggestion and that there is no guarantee of implementation.
|
||||||
41
.github/ISSUE_TEMPLATE/improvement.md
vendored
Normal file
41
.github/ISSUE_TEMPLATE/improvement.md
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: General Improvement
|
||||||
|
about: Suggest a minor enhancement or improvement for this project
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--- Please provide a clear and concise title for your improvement suggestion -->
|
||||||
|
|
||||||
|
## Improvement Description
|
||||||
|
|
||||||
|
<!--- Describe the improvement you are suggesting in detail. -->
|
||||||
|
<!--- Explain what specific aspect of the project it addresses or enhances. -->
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
<!--- Explain why this improvement would be beneficial. -->
|
||||||
|
<!--- Share any context, pain points, or reasons for suggesting this change. -->
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
<!--- If you have a suggestion for how this improvement could be implemented, describe it here. -->
|
||||||
|
<!--- Include any technical details, design suggestions, or other relevant information. -->
|
||||||
|
|
||||||
|
## Alternatives (optional)
|
||||||
|
|
||||||
|
<!--- Are there any alternative approaches to achieve the same improvement? -->
|
||||||
|
<!--- Describe other ways to address the issue or enhance the project. -->
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
|
||||||
|
<!--- Add any additional context or information that might be relevant to the improvement suggestion. -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
<!--- Please check the boxes that apply to this improvement suggestion. -->
|
||||||
|
<!--- You can add or remove items as needed. -->
|
||||||
|
|
||||||
|
- [ ] I have searched the existing issues and improvement suggestions to avoid duplication.
|
||||||
|
- [ ] I have provided a clear description of the improvement being suggested.
|
||||||
|
- [ ] I have explained the rationale behind this improvement.
|
||||||
|
- [ ] I have included any relevant technical details or design suggestions.
|
||||||
|
- [ ] I understand that this is a suggestion and that there is no guarantee of implementation.
|
||||||
49
.github/PULL_REQUEST_TEMPLATE/generic.md
vendored
Normal file
49
.github/PULL_REQUEST_TEMPLATE/generic.md
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: Pull Request
|
||||||
|
about: Submit changes to the project for review and inclusion
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!--- Describe the changes introduced by this pull request. -->
|
||||||
|
<!--- Explain what problem it solves or what feature/fix it adds. -->
|
||||||
|
|
||||||
|
## Related Issue
|
||||||
|
|
||||||
|
<!--- If this pull request is related to a specific issue, reference it here using #issue_number. -->
|
||||||
|
<!--- For example, "Fixes #123" or "Addresses #456". -->
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
<!--- Provide a summary of the changes made in this pull request. -->
|
||||||
|
<!--- Include any relevant technical details or architecture changes. -->
|
||||||
|
|
||||||
|
- Change 1
|
||||||
|
- Change 2
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Testing Performed
|
||||||
|
|
||||||
|
<!--- Describe the testing that you have performed to validate these changes. -->
|
||||||
|
<!--- Include information about test cases, testing environments, and results. -->
|
||||||
|
|
||||||
|
- Tested feature X in scenario Y.
|
||||||
|
- Ran unit tests for component Z.
|
||||||
|
- Tested on browsers A, B, and C.
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
<!--- Please check the boxes that apply to this pull request. -->
|
||||||
|
<!--- You can add or remove items as needed. -->
|
||||||
|
|
||||||
|
- [ ] I have tested these changes locally and they work as expected.
|
||||||
|
- [ ] I have added/updated tests that prove the effectiveness of these changes.
|
||||||
|
- [ ] I have updated the documentation to reflect these changes, if applicable.
|
||||||
|
- [ ] I have followed the project's coding style guidelines.
|
||||||
|
- [ ] I have addressed the code review feedback from the previous submission, if applicable.
|
||||||
|
|
||||||
|
## Additional Notes
|
||||||
|
|
||||||
|
<!--- Provide any additional context or notes for the reviewers. -->
|
||||||
|
<!--- This might include details about design decisions, potential concerns, or anything else relevant. -->
|
||||||
40
.github/PULL_REQUEST_TEMPLATE/test-addition.md
vendored
Normal file
40
.github/PULL_REQUEST_TEMPLATE/test-addition.md
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: Test Addition
|
||||||
|
about: Submit a new test, either unit or end-to-end (E2E), for review and inclusion
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!--- Provide a clear and concise description of the new test you are adding. -->
|
||||||
|
<!--- Explain the purpose of the test and what it aims to validate. -->
|
||||||
|
|
||||||
|
## Related Issue
|
||||||
|
|
||||||
|
<!--- If this test addition is related to a specific issue, reference it here using #issue_number. -->
|
||||||
|
<!--- For example, "Fixes #123" or "Addresses #456". -->
|
||||||
|
|
||||||
|
## Test Details
|
||||||
|
|
||||||
|
<!--- Describe the details of the test you're adding. -->
|
||||||
|
<!--- Include information about inputs, expected outputs, and any specific scenarios. -->
|
||||||
|
|
||||||
|
- Test Name: Name of the test
|
||||||
|
- Type: [Unit / E2E]
|
||||||
|
- Description: Brief description of what the test checks
|
||||||
|
- Inputs: What inputs the test uses (if applicable)
|
||||||
|
- Expected Output: What output or behavior the test expects
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
<!--- Please check the boxes that apply to this pull request. -->
|
||||||
|
<!--- You can add or remove items as needed. -->
|
||||||
|
|
||||||
|
- [ ] I have written the new test and ensured it works as intended.
|
||||||
|
- [ ] I have added necessary documentation to explain the purpose of the test.
|
||||||
|
- [ ] I have followed the project's testing guidelines and coding style.
|
||||||
|
- [ ] I have addressed any review feedback from previous submissions, if applicable.
|
||||||
|
|
||||||
|
## Additional Notes
|
||||||
|
|
||||||
|
<!--- Provide any additional context or notes for the reviewers. -->
|
||||||
|
<!--- This might include explanations about the testing approach or any potential concerns. -->
|
||||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -9,7 +9,7 @@ updates:
|
|||||||
labels:
|
labels:
|
||||||
- "ci dependencies"
|
- "ci dependencies"
|
||||||
- "ci"
|
- "ci"
|
||||||
open-pull-requests-limit: 2
|
open-pull-requests-limit: 0
|
||||||
|
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/apps/marketing"
|
directory: "/apps/marketing"
|
||||||
@@ -19,7 +19,7 @@ updates:
|
|||||||
labels:
|
labels:
|
||||||
- "npm dependencies"
|
- "npm dependencies"
|
||||||
- "frontend"
|
- "frontend"
|
||||||
open-pull-requests-limit: 2
|
open-pull-requests-limit: 0
|
||||||
|
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/apps/web"
|
directory: "/apps/web"
|
||||||
@@ -29,4 +29,4 @@ updates:
|
|||||||
labels:
|
labels:
|
||||||
- "npm dependencies"
|
- "npm dependencies"
|
||||||
- "frontend"
|
- "frontend"
|
||||||
open-pull-requests-limit: 2
|
open-pull-requests-limit: 0
|
||||||
|
|||||||
51
.github/workflows/e2e-tests.yml
vendored
Normal file
51
.github/workflows/e2e-tests.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: Playwright Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [feat/refresh]
|
||||||
|
pull_request:
|
||||||
|
branches: [feat/refresh]
|
||||||
|
jobs:
|
||||||
|
e2e_tests:
|
||||||
|
timeout-minutes: 60
|
||||||
|
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:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Copy env
|
||||||
|
run: cp .env.example .env
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
- name: Generate Prisma Client
|
||||||
|
run: npm run prisma:generate -w @documenso/prisma
|
||||||
|
- name: Create the database
|
||||||
|
run: npm run prisma:migrate-dev
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: npm run ci
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 30
|
||||||
|
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_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,6 +31,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# turbo
|
# turbo
|
||||||
.turbo
|
.turbo
|
||||||
|
.turbo-cookie
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
55
.gitpod.yml
Normal file
55
.gitpod.yml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
tasks:
|
||||||
|
- init: |
|
||||||
|
npm i &&
|
||||||
|
npm run dx:up &&
|
||||||
|
cp .env.example .env &&
|
||||||
|
set -a; source .env &&
|
||||||
|
export NEXTAUTH_URL="$(gp url 3000)" &&
|
||||||
|
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
|
||||||
|
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
|
||||||
|
command: npm run d
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- port: 3000
|
||||||
|
visibility: public
|
||||||
|
onOpen: open-preview
|
||||||
|
- port: 3001
|
||||||
|
visibility: public
|
||||||
|
onOpen: open-preview
|
||||||
|
- port: 9000
|
||||||
|
visibility: public
|
||||||
|
onOpen: ignore
|
||||||
|
- port: 1100
|
||||||
|
visibility: private
|
||||||
|
onOpen: ignore
|
||||||
|
- port: 2500
|
||||||
|
visibility: private
|
||||||
|
onOpen: ignore
|
||||||
|
- port: 54320
|
||||||
|
visibility: private
|
||||||
|
onOpen: ignore
|
||||||
|
|
||||||
|
|
||||||
|
github:
|
||||||
|
prebuilds:
|
||||||
|
master: true
|
||||||
|
pullRequests: true
|
||||||
|
pullRequestsFromForks: true
|
||||||
|
addCheck: true
|
||||||
|
addComment: true
|
||||||
|
addBadge: true
|
||||||
|
|
||||||
|
vscode:
|
||||||
|
extensions:
|
||||||
|
- aaron-bond.better-comments
|
||||||
|
- bradlc.vscode-tailwindcss
|
||||||
|
- dbaeumer.vscode-eslint
|
||||||
|
- esbenp.prettier-vscode
|
||||||
|
- mikestead.dotenv
|
||||||
|
- unifiedjs.vscode-mdx
|
||||||
|
- GitHub.copilot-chat
|
||||||
|
- GitHub.copilot-labs
|
||||||
|
- GitHub.copilot
|
||||||
|
- GitHub.vscode-pull-request-github
|
||||||
|
- Prisma.prisma
|
||||||
|
- VisualStudioExptTeam.vscodeintellicode
|
||||||
@@ -5,8 +5,8 @@ If you plan to contribute to Documenso, please take a moment to feel awesome ✨
|
|||||||
## Before getting started
|
## Before getting started
|
||||||
|
|
||||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/documenso/documenso/pulls) or [issues](https://github.com/documenso/documenso/issues) for an open or closed item that relates to your submission.
|
- Before jumping into a PR be sure to search [existing PRs](https://github.com/documenso/documenso/pulls) or [issues](https://github.com/documenso/documenso/issues) for an open or closed item that relates to your submission.
|
||||||
- Select and issue from [here](https://github.com/documenso/documenso/issues) or create a new one
|
- Select an issue from [here](https://github.com/documenso/documenso/issues) or create a new one
|
||||||
- Consider the results from the discussion in the issue
|
- Consider the results from the discussion on the issue
|
||||||
- Accept the [Contributor License Agreement](https://documen.so/cla) to ensure we can accept your contributions.
|
- Accept the [Contributor License Agreement](https://documen.so/cla) to ensure we can accept your contributions.
|
||||||
|
|
||||||
## Taking issues
|
## Taking issues
|
||||||
@@ -27,14 +27,14 @@ Always feel free to ask questions or seek clarification on the issue.
|
|||||||
|
|
||||||
## Developing
|
## Developing
|
||||||
|
|
||||||
The development branch is <code>main</code>. All pull request should be made against this branch. If you need help getting started, [join us on Discord](https://documen.so/discord).
|
The development branch is <code>main</code>. All pull requests should be made against this branch. If you need help getting started, [join us on Discord](https://documen.so/discord).
|
||||||
|
|
||||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your
|
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your
|
||||||
own GitHub account and then
|
own GitHub account and then
|
||||||
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||||
2. Create a new branch:
|
2. Create a new branch:
|
||||||
|
|
||||||
- Create a new branch (include the issue id and somthing readable):
|
- Create a new branch (include the issue id and something readable):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git checkout -b doc-999-my-feature-or-fix
|
git checkout -b doc-999-my-feature-or-fix
|
||||||
@@ -45,7 +45,7 @@ The development branch is <code>main</code>. All pull request should be made aga
|
|||||||
## Building
|
## Building
|
||||||
|
|
||||||
> **Note**
|
> **Note**
|
||||||
> Please be sure that you can make a full production build before pushing code or creating PRs.
|
> Please ensure you can make a full production build before pushing code or creating PRs.
|
||||||
|
|
||||||
You can build the project with:
|
You can build the project with:
|
||||||
|
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -179,7 +179,7 @@ git clone https://github.com/documenso/documenso
|
|||||||
- NEXT_PRIVATE_SMTP_FROM_NAME
|
- NEXT_PRIVATE_SMTP_FROM_NAME
|
||||||
- NEXT_PRIVATE_SMTP_FROM_ADDRESS
|
- NEXT_PRIVATE_SMTP_FROM_ADDRESS
|
||||||
|
|
||||||
5. Create the database schema by running `npm run prisma:migrate-dev -w @documenso/prisma`
|
5. Create the database schema by running `npm run prisma:migrate-dev`
|
||||||
|
|
||||||
6. Run `npm run dev` root directory to start
|
6. Run `npm run dev` root directory to start
|
||||||
|
|
||||||
@@ -254,6 +254,22 @@ containers:
|
|||||||
- '::'
|
- '::'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### I can't see environment variables in my package scripts
|
||||||
|
|
||||||
|
Wrap your package script with the `with:env` script like such:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run with:env -- npm run myscript
|
||||||
|
```
|
||||||
|
|
||||||
|
The same can be done when using `npx` for one of bin scripts:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run with:env -- npx myscript
|
||||||
|
```
|
||||||
|
|
||||||
|
This will load environment variables from your `.env` and `.env.local` files.
|
||||||
|
|
||||||
## Repo Activity
|
## Repo Activity
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { withContentlayer } = require('next-contentlayer');
|
const { withContentlayer } = require('next-contentlayer');
|
||||||
|
|
||||||
|
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
||||||
|
|
||||||
|
ENV_FILES.forEach((file) => {
|
||||||
require('dotenv').config({
|
require('dotenv').config({
|
||||||
path: path.join(__dirname, '../../.env.local'),
|
path: path.join(__dirname, `../../${file}`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
@@ -22,6 +26,14 @@ const config = {
|
|||||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
webpack: (config, { isServer }) => {
|
||||||
|
// fixes: Module not found: Can’t resolve ‘../build/Release/canvas.node’
|
||||||
|
if (isServer) {
|
||||||
|
config.resolve.alias.canvas = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "13.4.19",
|
"next": "13.5.4",
|
||||||
"next-auth": "4.22.3",
|
"next-auth": "4.22.3",
|
||||||
"next-contentlayer": "^0.3.4",
|
"next-contentlayer": "^0.3.4",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
@@ -31,10 +31,10 @@
|
|||||||
"react-confetti": "^6.1.0",
|
"react-confetti": "^6.1.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.11.0",
|
||||||
"recharts": "^2.7.2",
|
"recharts": "^2.7.2",
|
||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"typescript": "5.1.6",
|
"typescript": "5.2.2",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
1
apps/marketing/process-env.d.ts
vendored
1
apps/marketing/process-env.d.ts
vendored
@@ -7,6 +7,7 @@ declare namespace NodeJS {
|
|||||||
|
|
||||||
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_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;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
import { Footer } from '~/components/(marketing)/footer';
|
import { Footer } from '~/components/(marketing)/footer';
|
||||||
@@ -13,6 +15,7 @@ export type MarketingLayoutProps = {
|
|||||||
|
|
||||||
export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||||
const [scrollY, setScrollY] = useState(0);
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
@@ -25,7 +28,11 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative max-w-[100vw] overflow-y-auto overflow-x-hidden pt-20 md:pt-28">
|
<div
|
||||||
|
className={cn('relative max-w-[100vw] pt-20 md:pt-28', {
|
||||||
|
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', {
|
className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', {
|
||||||
'bg-background/50 backdrop-blur-md': scrollY > 5,
|
'bg-background/50 backdrop-blur-md': scrollY > 5,
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ export const TEAM_MEMBERS = [
|
|||||||
engagement: 'Full-Time',
|
engagement: 'Full-Time',
|
||||||
joinDate: 'July 26th, 2023',
|
joinDate: 'July 26th, 2023',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Catalin-Marinel Pit',
|
||||||
|
role: 'Software Engineer - II',
|
||||||
|
salary: 80_000,
|
||||||
|
location: 'Romania',
|
||||||
|
engagement: 'Full-Time',
|
||||||
|
joinDate: 'September 4th, 2023',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gowdhama Rajan B',
|
||||||
|
role: 'Designer - III',
|
||||||
|
salary: 100_000,
|
||||||
|
location: 'India',
|
||||||
|
engagement: 'Full-Time',
|
||||||
|
joinDate: 'October 9th, 2023',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FUNDING_RAISED = [
|
export const FUNDING_RAISED = [
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import { createSinglePlayerDocument } from '~/components/(marketing)/single-play
|
|||||||
|
|
||||||
type SinglePlayerModeStep = 'fields' | 'sign';
|
type SinglePlayerModeStep = 'fields' | 'sign';
|
||||||
|
|
||||||
export default function SinglePlayerModePage() {
|
// !: This entire file is a hack to get around failed prerendering of
|
||||||
|
// !: the Single Player Mode page. This regression was introduced during
|
||||||
|
// !: the upgrade of Next.js to v13.5.x.
|
||||||
|
export const SinglePlayerClient = () => {
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -130,7 +133,7 @@ export default function SinglePlayerModePage() {
|
|||||||
signer: data.email,
|
signer: data.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(`/single-player-mode/${documentToken}/success`);
|
router.push(`/singleplayer/${documentToken}/success`);
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: 'Something went wrong',
|
||||||
@@ -241,4 +244,4 @@ export default function SinglePlayerModePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
10
apps/marketing/src/app/(marketing)/singleplayer/page.tsx
Normal file
10
apps/marketing/src/app/(marketing)/singleplayer/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { SinglePlayerClient } from './client';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
// !: This entire file is a hack to get around failed prerendering of
|
||||||
|
// !: the Single Player Mode page. This regression was introduced during
|
||||||
|
// !: the upgrade of Next.js to v13.5.x.
|
||||||
|
export default function SingleplayerPage() {
|
||||||
|
return <SinglePlayerClient />;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Caveat, Inter } from 'next/font/google';
|
|||||||
|
|
||||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||||
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||||
|
|
||||||
@@ -63,7 +64,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<body>
|
<body>
|
||||||
<FeatureFlagProvider initialFlags={flags}>
|
<FeatureFlagProvider initialFlags={flags}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<PlausibleProvider>{children}</PlausibleProvider>
|
<PlausibleProvider>
|
||||||
|
<TrpcProvider>{children}</TrpcProvider>
|
||||||
|
</PlausibleProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</FeatureFlagProvider>
|
</FeatureFlagProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Github } from 'lucide-react';
|
|
||||||
import { usePlausible } from 'next-plausible';
|
import { usePlausible } from 'next-plausible';
|
||||||
|
import { LuGithub } from 'react-icons/lu';
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ export const Callout = ({ starCount }: CalloutProps) => {
|
|||||||
onClick={() => event('view-github')}
|
onClick={() => event('view-github')}
|
||||||
>
|
>
|
||||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
||||||
<Github className="mr-2 h-5 w-5" />
|
<LuGithub className="mr-2 h-5 w-5" />
|
||||||
Star on Github
|
Star on Github
|
||||||
{starCount && starCount > 0 && (
|
{starCount && starCount > 0 && (
|
||||||
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ import { claimPlan } from '~/api/claim-plan/fetcher';
|
|||||||
import { FormErrorMessage } from '../form/form-error-message';
|
import { FormErrorMessage } from '../form/form-error-message';
|
||||||
|
|
||||||
export const ZClaimPlanDialogFormSchema = z.object({
|
export const ZClaimPlanDialogFormSchema = z.object({
|
||||||
name: z.string().min(3),
|
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,8 +55,8 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
|
reset,
|
||||||
} = useForm<TClaimPlanDialogFormSchema>({
|
} = useForm<TClaimPlanDialogFormSchema>({
|
||||||
mode: 'onBlur',
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: params?.get('name') ?? '',
|
name: params?.get('name') ?? '',
|
||||||
email: params?.get('email') ?? '',
|
email: params?.get('email') ?? '',
|
||||||
@@ -91,6 +91,12 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSubmitting && !open) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
|||||||
@@ -5,27 +5,30 @@ import { HTMLAttributes } from 'react';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Github, MessagesSquare, Moon, Sun, Twitter } from 'lucide-react';
|
import { Moon, Sun } from 'lucide-react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
|
import { FaXTwitter } from 'react-icons/fa6';
|
||||||
|
import { LiaDiscord } from 'react-icons/lia';
|
||||||
|
import { LuGithub } from 'react-icons/lu';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
const SOCIAL_LINKS = [
|
const SOCIAL_LINKS = [
|
||||||
{ href: 'https://twitter.com/documenso', icon: <Twitter className="h-6 w-6" /> },
|
{ href: 'https://twitter.com/documenso', icon: <FaXTwitter className="h-6 w-6" /> },
|
||||||
{ href: 'https://github.com/documenso/documenso', icon: <Github className="h-6 w-6" /> },
|
{ href: 'https://github.com/documenso/documenso', icon: <LuGithub className="h-6 w-6" /> },
|
||||||
{ href: 'https://documen.so/discord', icon: <MessagesSquare className="h-6 w-6" /> },
|
{ href: 'https://documen.so/discord', icon: <LiaDiscord className="h-7 w-7" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FOOTER_LINKS = [
|
const FOOTER_LINKS = [
|
||||||
{ href: '/pricing', text: 'Pricing' },
|
{ href: '/pricing', text: 'Pricing' },
|
||||||
{ href: '/single-player-mode', text: 'Single Player Mode' },
|
{ href: '/singleplayer', text: 'Singleplayer' },
|
||||||
{ href: '/blog', text: 'Blog' },
|
{ href: '/blog', text: 'Blog' },
|
||||||
{ href: '/open', text: 'Open' },
|
{ href: '/open', text: 'Open' },
|
||||||
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
||||||
{ 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' },
|
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
|
||||||
{ href: '/privacy', text: 'Privacy' },
|
{ href: '/privacy', text: 'Privacy' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
|||||||
|
|
||||||
{isSinglePlayerModeMarketingEnabled && (
|
{isSinglePlayerModeMarketingEnabled && (
|
||||||
<Link
|
<Link
|
||||||
href="/single-player-mode"
|
href="/singleplayer"
|
||||||
className="bg-primary dark:text-background rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
|
className="bg-primary dark:text-background rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
|
||||||
>
|
>
|
||||||
Try now!
|
Try now!
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import Image from 'next/image';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Variants, motion } from 'framer-motion';
|
import { Variants, motion } from 'framer-motion';
|
||||||
import { Github } from 'lucide-react';
|
|
||||||
import { usePlausible } from 'next-plausible';
|
import { usePlausible } from 'next-plausible';
|
||||||
|
import { LuGithub } from 'react-icons/lu';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
@@ -122,7 +122,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
|
|
||||||
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
|
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
|
||||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
||||||
<Github className="mr-2 h-5 w-5" />
|
<LuGithub className="mr-2 h-5 w-5" />
|
||||||
Star on Github
|
Star on Github
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -134,9 +134,9 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
variants={HeroTitleVariants}
|
variants={HeroTitleVariants}
|
||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition duration-300"
|
className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition-colors duration-300"
|
||||||
>
|
>
|
||||||
<Link href="/single-player-mode" className="block px-4 py-2 text-center">
|
<Link href="/singleplayer" className="block px-4 py-2 text-center">
|
||||||
<h2 className="text-muted-foreground text-xs font-semibold">
|
<h2 className="text-muted-foreground text-xs font-semibold">
|
||||||
Introducing Single Player Mode
|
Introducing Single Player Mode
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import Image from 'next/image';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { motion, useReducedMotion } from 'framer-motion';
|
import { motion, useReducedMotion } from 'framer-motion';
|
||||||
import { Github, MessagesSquare, Twitter } from 'lucide-react';
|
import { FaXTwitter } from 'react-icons/fa6';
|
||||||
|
import { LiaDiscord } from 'react-icons/lia';
|
||||||
|
import { LuGithub } from 'react-icons/lu';
|
||||||
|
|
||||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||||
|
|
||||||
@@ -15,8 +17,8 @@ export type MobileNavigationProps = {
|
|||||||
|
|
||||||
export const MENU_NAVIGATION_LINKS = [
|
export const MENU_NAVIGATION_LINKS = [
|
||||||
{
|
{
|
||||||
href: '/single-player-mode',
|
href: '/singleplayer',
|
||||||
text: 'Single Player Mode',
|
text: 'Singleplayer',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/blog',
|
href: '/blog',
|
||||||
@@ -37,6 +39,7 @@ export const MENU_NAVIGATION_LINKS = [
|
|||||||
{
|
{
|
||||||
href: 'mailto:support@documenso.com',
|
href: 'mailto:support@documenso.com',
|
||||||
text: 'Support',
|
text: 'Support',
|
||||||
|
target: '_blank',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/privacy',
|
href: '/privacy',
|
||||||
@@ -76,7 +79,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
staggerChildren: 0.03,
|
staggerChildren: 0.03,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
|
{MENU_NAVIGATION_LINKS.map(({ href, text, target }) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={href}
|
key={href}
|
||||||
variants={{
|
variants={{
|
||||||
@@ -98,6 +101,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
||||||
href={href}
|
href={href}
|
||||||
onClick={() => handleMenuItemClick()}
|
onClick={() => handleMenuItemClick()}
|
||||||
|
target={target}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -111,7 +115,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-foreground hover:text-foreground/80"
|
className="text-foreground hover:text-foreground/80"
|
||||||
>
|
>
|
||||||
<Twitter className="h-6 w-6" />
|
<FaXTwitter className="h-6 w-6" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
@@ -119,7 +123,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-foreground hover:text-foreground/80"
|
className="text-foreground hover:text-foreground/80"
|
||||||
>
|
>
|
||||||
<Github className="h-6 w-6" />
|
<LuGithub className="h-6 w-6" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
@@ -127,7 +131,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-foreground hover:text-foreground/80"
|
className="text-foreground hover:text-foreground/80"
|
||||||
>
|
>
|
||||||
<MessagesSquare className="h-6 w-6" />
|
<LiaDiscord className="h-7 w-7" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
|
||||||
|
|
||||||
export type PasswordRevealProps = {
|
export type PasswordRevealProps = {
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Share } from 'lucide-react';
|
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
import { 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 { SigningCard3D } from '@documenso/ui/components/signing-card';
|
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';
|
||||||
@@ -87,11 +86,11 @@ export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerMod
|
|||||||
<div className="relative mt-8 w-full">
|
<div className="relative mt-8 w-full">
|
||||||
<div className={cn('flex flex-col items-center', className)}>
|
<div className={cn('flex flex-col items-center', className)}>
|
||||||
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
|
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
|
||||||
{/* TODO: Hook this up */}
|
<DocumentShareButton
|
||||||
<Button variant="outline" className="flex-1 bg-transparent backdrop-blur-sm" disabled>
|
documentId={document.id}
|
||||||
<Share className="mr-2 h-5 w-5" />
|
token={document.Recipient.token}
|
||||||
Share
|
className="flex-1 bg-transparent backdrop-blur-sm"
|
||||||
</Button>
|
/>
|
||||||
|
|
||||||
<DocumentDownloadButton
|
<DocumentDownloadButton
|
||||||
className="flex-1 bg-transparent backdrop-blur-sm"
|
className="flex-1 bg-transparent backdrop-blur-sm"
|
||||||
@@ -103,7 +102,7 @@ export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerMod
|
|||||||
<Button
|
<Button
|
||||||
onClick={async () => onShowDocumentClick()}
|
onClick={async () => onShowDocumentClick()}
|
||||||
loading={isFetchingDocumentFile}
|
loading={isFetchingDocumentFile}
|
||||||
className="col-span-2"
|
className="z-10 col-span-2"
|
||||||
>
|
>
|
||||||
Show document
|
Show document
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { FormErrorMessage } from '../form/form-error-message';
|
|||||||
const ZWidgetFormSchema = z
|
const ZWidgetFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email({ message: 'Please enter a valid email address.' }),
|
email: z.string().email({ message: 'Please enter a valid email address.' }),
|
||||||
name: z.string().min(3, { message: 'Please enter a valid name.' }),
|
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
|
||||||
})
|
})
|
||||||
.and(
|
.and(
|
||||||
z.union([
|
z.union([
|
||||||
@@ -41,7 +41,7 @@ const ZWidgetFormSchema = z
|
|||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
signatureDataUrl: z.null().or(z.string().max(0)),
|
signatureDataUrl: z.null().or(z.string().max(0)),
|
||||||
signatureText: z.string().min(1),
|
signatureText: z.string().trim().min(1),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
@@ -377,7 +377,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={showSigningDialog} onOpenChange={setShowSigningDialog}>
|
<Dialog open={showSigningDialog} onOpenChange={setShowSigningDialog}>
|
||||||
<DialogContent>
|
<DialogContent position="center">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add your signature</DialogTitle>
|
<DialogTitle>Add your signature</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -391,6 +391,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
|
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="aspect-video w-full rounded-md border"
|
className="aspect-video w-full rounded-md border"
|
||||||
|
defaultValue={signatureDataUrl || ''}
|
||||||
onChange={setDraftSignatureDataUrl}
|
onChange={setDraftSignatureDataUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
export type CopiedValue = string | null;
|
|
||||||
export type CopyFn = (_text: string) => Promise<boolean>;
|
|
||||||
|
|
||||||
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
|
|
||||||
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
|
|
||||||
|
|
||||||
const copy: CopyFn = async (text) => {
|
|
||||||
if (!navigator?.clipboard) {
|
|
||||||
console.warn('Clipboard not supported');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to save to clipboard then save it in the state if worked
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
setCopiedText(text);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Copy failed', error);
|
|
||||||
setCopiedText(null);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return [copiedText, copy];
|
|
||||||
}
|
|
||||||
@@ -41,7 +41,7 @@ export default async function handler(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user && user.Subscription.length > 0) {
|
if (user && user.Subscription) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
|
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
|
||||||
});
|
});
|
||||||
|
|||||||
8
apps/marketing/src/pages/api/trpc/[trpc].ts
Normal file
8
apps/marketing/src/pages/api/trpc/[trpc].ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import * as trpcNext from '@documenso/trpc/server/adapters/next';
|
||||||
|
import { createTrpcContext } from '@documenso/trpc/server/context';
|
||||||
|
import { appRouter } from '@documenso/trpc/server/router';
|
||||||
|
|
||||||
|
export default trpcNext.createNextApiHandler({
|
||||||
|
router: appRouter,
|
||||||
|
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
||||||
|
});
|
||||||
@@ -2,8 +2,12 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { version } = require('./package.json');
|
const { version } = require('./package.json');
|
||||||
|
|
||||||
|
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
||||||
|
|
||||||
|
ENV_FILES.forEach((file) => {
|
||||||
require('dotenv').config({
|
require('dotenv').config({
|
||||||
path: path.join(__dirname, '../../.env.local'),
|
path: path.join(__dirname, `../../${file}`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
@@ -29,6 +33,14 @@ const config = {
|
|||||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
webpack: (config, { isServer }) => {
|
||||||
|
// fixes: Module not found: Can’t resolve ‘../build/Release/canvas.node’
|
||||||
|
if (isServer) {
|
||||||
|
config.resolve.alias.canvas = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -37,6 +49,32 @@ const config = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
permanent: true,
|
||||||
|
source: '/documents/:id/sign',
|
||||||
|
destination: '/sign/:token',
|
||||||
|
has: [
|
||||||
|
{
|
||||||
|
type: 'query',
|
||||||
|
key: 'token',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permanent: true,
|
||||||
|
source: '/documents/:id/signed',
|
||||||
|
destination: '/sign/:token',
|
||||||
|
has: [
|
||||||
|
{
|
||||||
|
type: 'query',
|
||||||
|
key: 'token',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"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": "13.4.19",
|
"next": "13.5.4",
|
||||||
"next-auth": "4.22.3",
|
"next-auth": "4.22.3",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
@@ -36,11 +36,11 @@
|
|||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.11.0",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.1.6",
|
"typescript": "5.2.2",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
1
apps/web/process-env.d.ts
vendored
1
apps/web/process-env.d.ts
vendored
@@ -7,6 +7,7 @@ declare namespace NodeJS {
|
|||||||
|
|
||||||
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_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;
|
||||||
|
|||||||
106
apps/web/src/app/(dashboard)/admin/documents/data-table.tsx
Normal file
106
apps/web/src/app/(dashboard)/admin/documents/data-table.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
|
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import { Document, User } from '@documenso/prisma/client';
|
||||||
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
|
||||||
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
export type DocumentsDataTableProps = {
|
||||||
|
results: FindResultSet<
|
||||||
|
Document & {
|
||||||
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
startTransition(() => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Created',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Title',
|
||||||
|
accessorKey: 'title',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<div className="block max-w-[5rem] truncate font-medium md:max-w-[10rem]">
|
||||||
|
{row.original.title}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Owner',
|
||||||
|
accessorKey: 'owner',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<Link href={`/admin/users/${row.original.User.id}`}>
|
||||||
|
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
||||||
|
<AvatarFallback className="text-gray-400">
|
||||||
|
<span className="text-sm">
|
||||||
|
{recipientInitials(row.original.User.name ?? '')}
|
||||||
|
</span>
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Last updated',
|
||||||
|
accessorKey: 'updatedAt',
|
||||||
|
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
29
apps/web/src/app/(dashboard)/admin/documents/page.tsx
Normal file
29
apps/web/src/app/(dashboard)/admin/documents/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
|
||||||
|
|
||||||
|
import { DocumentsDataTable } from './data-table';
|
||||||
|
|
||||||
|
export type DocumentsPageProps = {
|
||||||
|
searchParams?: {
|
||||||
|
page?: string;
|
||||||
|
perPage?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Documents({ searchParams = {} }: DocumentsPageProps) {
|
||||||
|
const page = Number(searchParams.page) || 1;
|
||||||
|
const perPage = Number(searchParams.perPage) || 20;
|
||||||
|
|
||||||
|
const results = await findDocuments({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-4xl font-semibold">Manage documents</h2>
|
||||||
|
<div className="mt-8">
|
||||||
|
<DocumentsDataTable results={results} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { BarChart3, User2 } from 'lucide-react';
|
import { BarChart3, FileStack, User2, Wallet2 } from 'lucide-react';
|
||||||
|
|
||||||
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';
|
||||||
@@ -37,10 +37,40 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
|||||||
'justify-start md:w-full',
|
'justify-start md:w-full',
|
||||||
pathname?.startsWith('/admin/users') && 'bg-secondary',
|
pathname?.startsWith('/admin/users') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
disabled
|
asChild
|
||||||
>
|
>
|
||||||
|
<Link href="/admin/users">
|
||||||
<User2 className="mr-2 h-5 w-5" />
|
<User2 className="mr-2 h-5 w-5" />
|
||||||
Users (Coming Soon)
|
Users
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'justify-start md:w-full',
|
||||||
|
pathname?.startsWith('/admin/documents') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/admin/documents">
|
||||||
|
<FileStack className="mr-2 h-5 w-5" />
|
||||||
|
Documents
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'justify-start md:w-full',
|
||||||
|
pathname?.startsWith('/admin/subscriptions') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/admin/subscriptions">
|
||||||
|
<Wallet2 className="mr-2 h-5 w-5" />
|
||||||
|
Subscriptions
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
65
apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx
Normal file
65
apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
export default async function Subscriptions() {
|
||||||
|
const subscriptions = await findSubscriptions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-4xl font-semibold">Manage subscriptions</h2>
|
||||||
|
<div className="mt-8">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Created At</TableHead>
|
||||||
|
<TableHead>Ends On</TableHead>
|
||||||
|
<TableHead>User ID</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{subscriptions.map((subscription, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell>{subscription.id}</TableCell>
|
||||||
|
<TableCell>{subscription.status}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{subscription.createdAt
|
||||||
|
? new Date(subscription.createdAt).toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
: 'N/A'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{subscription.periodEnd
|
||||||
|
? new Date(subscription.periodEnd).toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
: 'N/A'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Link href={`/admin/users/${subscription.userId}`}>{subscription.userId}</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
Normal file
141
apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
||||||
|
|
||||||
|
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||||
|
|
||||||
|
export default function UserPage({ params }: { params: { id: number } }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { data: user } = trpc.profile.getUser.useQuery(
|
||||||
|
{
|
||||||
|
id: Number(params.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!params.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const roles = user?.roles ?? [];
|
||||||
|
|
||||||
|
const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<TUserFormSchema>({
|
||||||
|
resolver: zodResolver(ZUserFormSchema),
|
||||||
|
values: {
|
||||||
|
name: user?.name ?? '',
|
||||||
|
email: user?.email ?? '',
|
||||||
|
roles: user?.roles ?? [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async ({ name, email, roles }: TUserFormSchema) => {
|
||||||
|
try {
|
||||||
|
await updateUserMutation({
|
||||||
|
id: Number(user?.id),
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
roles,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Profile updated',
|
||||||
|
description: 'Your profile has been updated.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while updating your profile.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-4xl font-semibold">Manage {user?.name}'s profile</h2>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<fieldset className="mt-6 flex w-full flex-col gap-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" {...field} value={field.value ?? ''} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="roles"
|
||||||
|
render={({ field: { onChange } }) => (
|
||||||
|
<FormItem>
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Combobox
|
||||||
|
listValues={roles}
|
||||||
|
onChange={(values: string[]) => onChange(values)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</fieldset>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
Update user
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx
Normal file
143
apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useTransition } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Edit, Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { Document, Role, Subscription } from '@documenso/prisma/client';
|
||||||
|
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 { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
type UserData = {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
roles: Role[];
|
||||||
|
Subscription?: SubscriptionLite | null;
|
||||||
|
Document: DocumentLite[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubscriptionLite = Pick<
|
||||||
|
Subscription,
|
||||||
|
'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type DocumentLite = Pick<Document, 'id'>;
|
||||||
|
|
||||||
|
type UsersDataTableProps = {
|
||||||
|
users: UserData[];
|
||||||
|
totalPages: number;
|
||||||
|
perPage: number;
|
||||||
|
page: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
const [searchString, setSearchString] = useState('');
|
||||||
|
const debouncedSearchString = useDebouncedValue(searchString, 1000);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startTransition(() => {
|
||||||
|
updateSearchParams({
|
||||||
|
search: debouncedSearchString,
|
||||||
|
page: 1,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [debouncedSearchString]);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
startTransition(() => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchString(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
className="my-6 flex flex-row gap-4"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or email"
|
||||||
|
value={searchString}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'ID',
|
||||||
|
accessorKey: 'id',
|
||||||
|
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: ({ row }) => <div>{row.original.name}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Email',
|
||||||
|
accessorKey: 'email',
|
||||||
|
cell: ({ row }) => <div>{row.original.email}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Roles',
|
||||||
|
accessorKey: 'roles',
|
||||||
|
cell: ({ row }) => row.original.roles.join(', '),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Subscription',
|
||||||
|
accessorKey: 'subscription',
|
||||||
|
cell: ({ row }) => row.original.Subscription?.status ?? 'NONE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Documents',
|
||||||
|
accessorKey: 'documents',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return <div>{row.original.Document.length}</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '',
|
||||||
|
accessorKey: 'edit',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<Button className="w-24" asChild>
|
||||||
|
<Link href={`/admin/users/${row.original.id}`}>
|
||||||
|
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={users}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
|
||||||
|
|
||||||
|
export async function search(search: string, page: number, perPage: number) {
|
||||||
|
const results = await findUsers({ username: search, email: search, page, perPage });
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
25
apps/web/src/app/(dashboard)/admin/users/page.tsx
Normal file
25
apps/web/src/app/(dashboard)/admin/users/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { UsersDataTable } from './data-table-users';
|
||||||
|
import { search } from './fetch-users.actions';
|
||||||
|
|
||||||
|
type AdminManageUsersProps = {
|
||||||
|
searchParams?: {
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) {
|
||||||
|
const page = Number(searchParams.page) || 1;
|
||||||
|
const perPage = Number(searchParams.perPage) || 10;
|
||||||
|
const searchString = searchParams.search || '';
|
||||||
|
|
||||||
|
const { users, totalPages } = await search(searchString, page, perPage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-4xl font-semibold">Manage users</h2>
|
||||||
|
<UsersDataTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,13 +6,15 @@ import { Edit, Pencil, Share } 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 { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
|
||||||
|
import {
|
||||||
|
TOAST_DOCUMENT_SHARE_ERROR,
|
||||||
|
TOAST_DOCUMENT_SHARE_SUCCESS,
|
||||||
|
} from '@documenso/lib/constants/toast';
|
||||||
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
|
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
|
||||||
|
|
||||||
export type DataTableActionButtonProps = {
|
export type DataTableActionButtonProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
@@ -22,16 +24,18 @@ export type DataTableActionButtonProps = {
|
|||||||
|
|
||||||
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [, copyToClipboard] = useCopyToClipboard();
|
|
||||||
|
const { createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({
|
||||||
|
onSuccess: () => toast(TOAST_DOCUMENT_SHARE_SUCCESS),
|
||||||
|
onError: () => toast(TOAST_DOCUMENT_SHARE_ERROR),
|
||||||
|
});
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
|
|
||||||
trpc.shareLink.createOrGetShareLink.useMutation();
|
|
||||||
|
|
||||||
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||||
|
|
||||||
const isOwner = row.User.id === session.user.id;
|
const isOwner = row.User.id === session.user.id;
|
||||||
@@ -41,20 +45,6 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
|
||||||
const onShareClick = async () => {
|
|
||||||
const { slug } = await createOrGetShareLink({
|
|
||||||
token: recipient?.token,
|
|
||||||
documentId: row.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Copied to clipboard',
|
|
||||||
description: 'The sharing link has been copied to your clipboard.',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isOwner,
|
isOwner,
|
||||||
isRecipient,
|
isRecipient,
|
||||||
@@ -80,8 +70,17 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.otherwise(() => (
|
.otherwise(() => (
|
||||||
<Button className="w-24" loading={isCreatingShareLink} onClick={onShareClick}>
|
<Button
|
||||||
{!isCreatingShareLink && <Share className="-ml-1 mr-2 h-4 w-4" />}
|
className="w-24"
|
||||||
|
loading={isCopyingShareLink}
|
||||||
|
onClick={async () =>
|
||||||
|
createAndCopyShareLink({
|
||||||
|
token: recipient?.token,
|
||||||
|
documentId: row.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!isCopyingShareLink && <Share className="-ml-1 mr-2 h-4 w-4" />}
|
||||||
Share
|
Share
|
||||||
</Button>
|
</Button>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -16,11 +18,15 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
|
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
|
||||||
|
import {
|
||||||
|
TOAST_DOCUMENT_SHARE_ERROR,
|
||||||
|
TOAST_DOCUMENT_SHARE_SUCCESS,
|
||||||
|
} from '@documenso/lib/constants/toast';
|
||||||
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 { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import { 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 { trpc as trpcReact } from '@documenso/trpc/react';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -30,7 +36,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
|
||||||
|
|
||||||
export type DataTableActionDropdownProps = {
|
export type DataTableActionDropdownProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
@@ -41,38 +47,29 @@ export type DataTableActionDropdownProps = {
|
|||||||
|
|
||||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [, copyToClipboard] = useCopyToClipboard();
|
|
||||||
|
const { createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({
|
||||||
|
onSuccess: () => toast(TOAST_DOCUMENT_SHARE_SUCCESS),
|
||||||
|
onError: () => toast(TOAST_DOCUMENT_SHARE_ERROR),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
|
|
||||||
trpcReact.shareLink.createOrGetShareLink.useMutation();
|
|
||||||
|
|
||||||
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||||
|
|
||||||
const isOwner = row.User.id === session.user.id;
|
const isOwner = row.User.id === session.user.id;
|
||||||
// const isRecipient = !!recipient;
|
// const isRecipient = !!recipient;
|
||||||
// const isDraft = row.status === DocumentStatus.DRAFT;
|
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||||
// 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 onShareClick = async () => {
|
|
||||||
const { slug } = await createOrGetShareLink({
|
|
||||||
token: recipient?.token,
|
|
||||||
documentId: row.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Copied to clipboard',
|
|
||||||
description: 'The sharing link has been copied to your clipboard.',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
let document: DocumentWithData | null = null;
|
let document: DocumentWithData | null = null;
|
||||||
@@ -147,7 +144,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
Void
|
Void
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled>
|
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -159,8 +156,16 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
Resend
|
Resend
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onShareClick}>
|
<DropdownMenuItem
|
||||||
{isCreatingShareLink ? (
|
disabled={isDraft}
|
||||||
|
onClick={async () =>
|
||||||
|
createAndCopyShareLink({
|
||||||
|
token: recipient?.token,
|
||||||
|
documentId: row.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isCopyingShareLink ? (
|
||||||
<Loader className="mr-2 h-4 w-4" />
|
<Loader className="mr-2 h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Share className="mr-2 h-4 w-4" />
|
<Share className="mr-2 h-4 w-4" />
|
||||||
@@ -168,6 +173,14 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
Share
|
Share
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
|
{isDocumentDeletable && (
|
||||||
|
<DeleteDraftDocumentDialog
|
||||||
|
id={row.id}
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
type DeleteDraftDocumentDialogProps = {
|
||||||
|
id: number;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeleteDraftDocumentDialog = ({
|
||||||
|
id,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: DeleteDraftDocumentDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteDocument, isLoading } =
|
||||||
|
trpcReact.document.deleteDraftDocument.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Document deleted',
|
||||||
|
description: 'Your document has been successfully deleted.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDraftDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteDocument({ id });
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'This document could not be deleted at this time. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Do you want to delete this document?</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
Please note that this action is irreversible. Once confirmed, your document will be
|
||||||
|
permanently deleted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<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={onDraftDelete} className="flex-1">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -66,7 +66,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
<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>
|
<h1 className="text-4xl font-semibold">Documents</h1>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-6 overflow-hidden">
|
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||||
<Tabs defaultValue={status} className="overflow-x-auto">
|
<Tabs defaultValue={status} className="overflow-x-auto">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{[
|
{[
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
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 { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
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 { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
@@ -22,6 +25,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { quota, remaining } = useLimits();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||||
@@ -52,11 +57,19 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
|
if (error instanceof TRPCClientError) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'An error occurred while uploading your document.',
|
description: 'An error occurred while uploading your document.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -64,13 +77,46 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
<DocumentDropzone className="min-h-[40vh]" onDrop={onFileDrop} />
|
<DocumentDropzone
|
||||||
|
className="min-h-[40vh]"
|
||||||
|
disabled={remaining.documents === 0}
|
||||||
|
onDrop={onFileDrop}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute -bottom-6 right-0">
|
||||||
|
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
|
||||||
|
<p className="text-muted-foreground/60 text-xs">
|
||||||
|
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||||
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{remaining.documents === 0 && (
|
||||||
|
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
||||||
|
You have reached your document limit.
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||||
|
You can upload up to {quota.documents} documents per month on your current plan.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
className="text-primary hover:text-primary/80 mt-6 block font-medium"
|
||||||
|
href="/settings/billing"
|
||||||
|
>
|
||||||
|
Upgrade your account to upload more documents.
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
|
|
||||||
|
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-session';
|
||||||
|
|
||||||
@@ -28,11 +29,13 @@ export default async function AuthenticatedDashboardLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NextAuthProvider session={session}>
|
<NextAuthProvider session={session}>
|
||||||
|
<LimitsProvider>
|
||||||
<Header user={user} />
|
<Header user={user} />
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
|
||||||
<RefreshOnFocus />
|
<RefreshOnFocus />
|
||||||
|
</LimitsProvider>
|
||||||
</NextAuthProvider>
|
</NextAuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
133
apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx
Normal file
133
apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
|
import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { createCheckout } from './create-checkout.action';
|
||||||
|
|
||||||
|
type Interval = keyof PriceIntervals;
|
||||||
|
|
||||||
|
const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
|
||||||
|
|
||||||
|
const FRIENDLY_INTERVALS: Record<Interval, string> = {
|
||||||
|
day: 'Daily',
|
||||||
|
week: 'Weekly',
|
||||||
|
month: 'Monthly',
|
||||||
|
year: 'Yearly',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MotionCard = motion(Card);
|
||||||
|
|
||||||
|
export type BillingPlansProps = {
|
||||||
|
prices: PriceIntervals;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
|
const [interval, setInterval] = useState<Interval>('month');
|
||||||
|
const [isFetchingCheckoutSession, setIsFetchingCheckoutSession] = useState(false);
|
||||||
|
|
||||||
|
const onSubscribeClick = async (priceId: string) => {
|
||||||
|
try {
|
||||||
|
setIsFetchingCheckoutSession(true);
|
||||||
|
|
||||||
|
const url = await createCheckout({ priceId });
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('Unable to create session');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(url);
|
||||||
|
} catch (_err) {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'An error occurred while trying to create a checkout session.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsFetchingCheckoutSession(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Tabs value={interval} onValueChange={(value) => isInterval(value) && setInterval(value)}>
|
||||||
|
<TabsList>
|
||||||
|
{INTERVALS.map(
|
||||||
|
(interval) =>
|
||||||
|
prices[interval].length > 0 && (
|
||||||
|
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
|
||||||
|
{FRIENDLY_INTERVALS[interval]}
|
||||||
|
</TabsTrigger>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{prices[interval].map((price) => (
|
||||||
|
<MotionCard
|
||||||
|
key={price.id}
|
||||||
|
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
|
||||||
|
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0.3 } }}
|
||||||
|
>
|
||||||
|
<CardContent className="flex h-full flex-col p-6">
|
||||||
|
<CardTitle>{price.product.name}</CardTitle>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground mt-2 text-lg font-medium">
|
||||||
|
${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '}
|
||||||
|
<span className="text-xs">per {interval}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground mt-1.5 text-sm">
|
||||||
|
{price.product.description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{price.product.features && price.product.features.length > 0 && (
|
||||||
|
<div className="text-muted-foreground mt-4">
|
||||||
|
<div className="text-sm font-medium">Includes:</div>
|
||||||
|
|
||||||
|
<ul className="mt-1 divide-y text-sm">
|
||||||
|
{price.product.features.map((feature, index) => (
|
||||||
|
<li key={index} className="py-2">
|
||||||
|
{feature.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="mt-4"
|
||||||
|
loading={isFetchingCheckoutSession}
|
||||||
|
onClick={() => void onSubscribeClick(price.id)}
|
||||||
|
>
|
||||||
|
Subscribe
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</MotionCard>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { createBillingPortal } from './create-billing-portal.action';
|
||||||
|
|
||||||
|
export const BillingPortalButton = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
||||||
|
|
||||||
|
const handleFetchPortalUrl = async () => {
|
||||||
|
if (isFetchingPortalUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFetchingPortalUrl(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionUrl = await createBillingPortal();
|
||||||
|
|
||||||
|
if (!sessionUrl) {
|
||||||
|
throw new Error('NO_SESSION');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(sessionUrl, '_blank');
|
||||||
|
} catch (e) {
|
||||||
|
let description =
|
||||||
|
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.';
|
||||||
|
|
||||||
|
if (e.message === 'CUSTOMER_NOT_FOUND') {
|
||||||
|
description =
|
||||||
|
'You do not currently have a customer record, this should not happen. Please contact support for assistance.';
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFetchingPortalUrl(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
|
||||||
|
Manage Subscription
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getStripeCustomerByEmail,
|
||||||
|
getStripeCustomerById,
|
||||||
|
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-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 () => {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
||||||
|
|
||||||
|
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({
|
||||||
|
customerId: stripeCustomer.id,
|
||||||
|
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||||
|
import {
|
||||||
|
getStripeCustomerByEmail,
|
||||||
|
getStripeCustomerById,
|
||||||
|
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||||
|
|
||||||
|
export type CreateCheckoutOptions = {
|
||||||
|
priceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPortalSession({
|
||||||
|
customerId: stripeCustomer.id,
|
||||||
|
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) {
|
||||||
|
stripeCustomer = await stripe.customers.create({
|
||||||
|
name: user.name ?? undefined,
|
||||||
|
email: user.email,
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCheckoutSession({
|
||||||
|
customerId: stripeCustomer.id,
|
||||||
|
priceId,
|
||||||
|
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
import { match } from 'ts-pattern';
|
||||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
|
||||||
|
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
|
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-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 { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
import { BillingPlans } from './billing-plans';
|
||||||
|
import { BillingPortalButton } from './billing-portal-button';
|
||||||
|
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
@@ -21,57 +24,73 @@ export default async function BillingSettingsPage() {
|
|||||||
redirect('/settings/profile');
|
redirect('/settings/profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => {
|
const [subscription, prices] = await Promise.all([
|
||||||
if (sub) {
|
getSubscriptionByUserId({ userId: user.id }),
|
||||||
return sub;
|
getPricesByInterval(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let subscriptionProduct: Stripe.Product | null = null;
|
||||||
|
|
||||||
|
if (subscription?.priceId) {
|
||||||
|
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we don't have a customer record, create one as well as an empty subscription.
|
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
|
||||||
return createCustomer({ user });
|
|
||||||
});
|
|
||||||
|
|
||||||
let billingPortalUrl = '';
|
|
||||||
|
|
||||||
if (subscription.customerId) {
|
|
||||||
billingPortalUrl = await getPortalSession({
|
|
||||||
customerId: subscription.customerId,
|
|
||||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Billing</h3>
|
<h3 className="text-lg font-medium">Billing</h3>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<div className="text-muted-foreground mt-2 text-sm">
|
||||||
Your subscription is{' '}
|
{isMissingOrInactiveOrFreePlan && (
|
||||||
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
|
<p>
|
||||||
{subscription?.periodEnd && (
|
You are currently on the <span className="font-semibold">Free Plan</span>.
|
||||||
<>
|
</p>
|
||||||
{' '}
|
)}
|
||||||
Your next payment is due on{' '}
|
|
||||||
<span className="font-semibold">
|
{!isMissingOrInactiveOrFreePlan &&
|
||||||
<LocaleDate date={subscription.periodEnd} />
|
match(subscription.status)
|
||||||
|
.with('ACTIVE', () => (
|
||||||
|
<p>
|
||||||
|
{subscriptionProduct ? (
|
||||||
|
<span>
|
||||||
|
You are currently subscribed to{' '}
|
||||||
|
<span className="font-semibold">{subscriptionProduct.name}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>You currently have an active plan</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subscription.periodEnd && (
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
which is set to{' '}
|
||||||
|
{subscription.cancelAtPeriodEnd ? (
|
||||||
|
<span>
|
||||||
|
end on{' '}
|
||||||
|
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
automatically renew on{' '}
|
||||||
|
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
.
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
))
|
||||||
|
.with('PAST_DUE', () => (
|
||||||
|
<p>Your current plan is past due. Please update your payment information.</p>
|
||||||
|
))
|
||||||
|
.otherwise(() => null)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
{billingPortalUrl && (
|
{isMissingOrInactiveOrFreePlan ? <BillingPlans prices={prices} /> : <BillingPortalButton />}
|
||||||
<Button asChild>
|
|
||||||
<Link href={billingPortalUrl}>Manage Subscription</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!billingPortalUrl && (
|
|
||||||
<p className="text-muted-foreground max-w-[60ch] text-base">
|
|
||||||
You do not currently have a customer record, this should not happen. Please contact
|
|
||||||
support for assistance.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
43
apps/web/src/app/(signing)/sign/[token]/chat-pdf.tsx
Normal file
43
apps/web/src/app/(signing)/sign/[token]/chat-pdf.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
import { loadFileIntoPinecone } from '@documenso/lib/server-only/pinecone';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
|
import { DocumentDataType } from '@documenso/prisma/client';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
|
import { Chat } from './chat';
|
||||||
|
|
||||||
|
type ChatPDFProps = {
|
||||||
|
id: string;
|
||||||
|
type: DocumentDataType;
|
||||||
|
data: string;
|
||||||
|
initialData: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function ChatPDF({ documentData }: { documentData: ChatPDFProps }) {
|
||||||
|
const docData = await getFile(documentData);
|
||||||
|
const fileName = `${documentData.id}}.pdf`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(fileName, fs.constants.F_OK);
|
||||||
|
} catch (err) {
|
||||||
|
await fs.writeFile(fileName, docData);
|
||||||
|
}
|
||||||
|
await loadFileIntoPinecone(fileName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="my-8" gradient={true} degrees={200}>
|
||||||
|
<CardContent className="mt-8 flex flex-col">
|
||||||
|
<h2 className="text-foreground text-2xl font-semibold">Chat with the PDF</h2>
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">Ask any questions regarding the PDF</p>
|
||||||
|
<hr className="border-border mb-4 mt-4" />
|
||||||
|
<Chat />
|
||||||
|
<hr className="border-border mb-4 mt-4" />
|
||||||
|
<p className="text-muted-foreground text-sm italic">
|
||||||
|
Disclaimer: Never trust AI 100%. Always double check the documents yourself. Documenso is
|
||||||
|
not liable for any issue arising from you relying 100% on the AI.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
apps/web/src/app/(signing)/sign/[token]/chat.tsx
Normal file
56
apps/web/src/app/(signing)/sign/[token]/chat.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useChat } from 'ai/react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
type Props = {};
|
||||||
|
|
||||||
|
export function Chat({}: Props) {
|
||||||
|
const { input, handleInputChange, handleSubmit, messages } = useChat({
|
||||||
|
api: '/api/chat',
|
||||||
|
});
|
||||||
|
|
||||||
|
// continue https://youtu.be/bZFedu-0emE?si=2JGSJfSQ38aXSlp2&t=10941
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<ul>
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<li
|
||||||
|
className={cn(
|
||||||
|
'flex',
|
||||||
|
message.role === 'user'
|
||||||
|
? 'mb-6 ml-10 mt-6 flex justify-end'
|
||||||
|
: 'mr-10 justify-start',
|
||||||
|
)}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
message.role === 'user'
|
||||||
|
? 'bg-background text-foreground group relative rounded-lg border-2 p-4 backdrop-blur-[2px]'
|
||||||
|
: 'bg-primary text-primary-foreground rounded-lg p-4 backdrop-blur-[2px]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<form className="mb-2 mt-8 flex" onSubmit={handleSubmit}>
|
||||||
|
<Input
|
||||||
|
value={input}
|
||||||
|
className="mr-6 w-1/2"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="Ask away..."
|
||||||
|
/>
|
||||||
|
<Button type="submit">Send</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,12 +9,11 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
|
|||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
||||||
import { 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 { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
|
|
||||||
import signingCelebration from '~/assets/signing-celebration.png';
|
import signingCelebration from '~/assets/signing-celebration.png';
|
||||||
|
|
||||||
import { ShareButton } from './share-button';
|
|
||||||
|
|
||||||
export type CompletedSigningPageProps = {
|
export type CompletedSigningPageProps = {
|
||||||
params: {
|
params: {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -89,7 +88,7 @@ export default async function CompletedSigningPage({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||||
<ShareButton documentId={document.id} token={recipient.token} />
|
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||||
|
|
||||||
<DocumentDownloadButton
|
<DocumentDownloadButton
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
Please review the document before signing.
|
Please review the document before signing.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
<hr className="border-border mb-8 mt-4 h-8 w-full" />
|
||||||
|
|
||||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||||
<div className="flex flex-1 flex-col gap-y-4">
|
<div className="flex flex-1 flex-col gap-y-4">
|
||||||
@@ -99,7 +99,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
id="full-name"
|
id="full-name"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
value={fullName}
|
value={fullName}
|
||||||
onChange={(e) => setFullName(e.target.value)}
|
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
type="text"
|
type="text"
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
value={localFullName}
|
value={localFullName}
|
||||||
onChange={(e) => setLocalFullName(e.target.value)}
|
onChange={(e) => setLocalFullName(e.target.value.trimStart())}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
|
import { ChatPDF } from './chat-pdf';
|
||||||
import { DateField } from './date-field';
|
import { DateField } from './date-field';
|
||||||
import { EmailField } from './email-field';
|
import { EmailField } from './email-field';
|
||||||
import { SigningForm } from './form';
|
import { SigningForm } from './form';
|
||||||
@@ -106,6 +107,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
.otherwise(() => null),
|
.otherwise(() => null),
|
||||||
)}
|
)}
|
||||||
</ElementVisible>
|
</ElementVisible>
|
||||||
|
<ChatPDF documentData={documentData} />
|
||||||
</div>
|
</div>
|
||||||
</SigningProvider>
|
</SigningProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState, useTransition } from 'react';
|
import { useEffect, useMemo, useState, useTransition } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
|
|
||||||
const [showSignatureModal, setShowSignatureModal] = useState(false);
|
const [showSignatureModal, setShowSignatureModal] = useState(false);
|
||||||
const [localSignature, setLocalSignature] = useState<string | null>(null);
|
const [localSignature, setLocalSignature] = useState<string | null>(null);
|
||||||
|
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
|
||||||
|
|
||||||
const state = useMemo<SignatureFieldState>(() => {
|
const state = useMemo<SignatureFieldState>(() => {
|
||||||
if (!field.inserted) {
|
if (!field.inserted) {
|
||||||
@@ -61,9 +62,16 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
return 'signed-text';
|
return 'signed-text';
|
||||||
}, [field.inserted, signature?.signatureImageAsBase64]);
|
}, [field.inserted, signature?.signatureImageAsBase64]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSignatureModal && !isLocalSignatureSet) {
|
||||||
|
setLocalSignature(null);
|
||||||
|
}
|
||||||
|
}, [showSignatureModal, isLocalSignatureSet]);
|
||||||
|
|
||||||
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
||||||
try {
|
try {
|
||||||
if (!providedSignature && !localSignature) {
|
if (!providedSignature && !localSignature) {
|
||||||
|
setIsLocalSignatureSet(false);
|
||||||
setShowSignatureModal(true);
|
setShowSignatureModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -178,6 +186,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
disabled={!localSignature}
|
disabled={!localSignature}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowSignatureModal(false);
|
setShowSignatureModal(false);
|
||||||
|
setIsLocalSignatureSet(true);
|
||||||
void onSign('local');
|
void onSign('local');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Github,
|
|
||||||
Key,
|
Key,
|
||||||
LogOut,
|
LogOut,
|
||||||
User as LucideUser,
|
User as LucideUser,
|
||||||
@@ -16,6 +15,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
|
import { LuGithub } from 'react-icons/lu';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
@@ -130,7 +130,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="https://github.com/documenso/documenso" className="cursor-pointer">
|
<Link href="https://github.com/documenso/documenso" className="cursor-pointer">
|
||||||
<Github className="mr-2 h-4 w-4" />
|
<LuGithub className="mr-2 h-4 w-4" />
|
||||||
Star on Github
|
Star on Github
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { FormErrorMessage } from '../form/form-error-message';
|
|||||||
|
|
||||||
export const ZPasswordFormSchema = z
|
export const ZPasswordFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
currentPassword: z.string().min(6).max(72),
|
||||||
password: z.string().min(6).max(72),
|
password: z.string().min(6).max(72),
|
||||||
repeatedPassword: z.string().min(6).max(72),
|
repeatedPassword: z.string().min(6).max(72),
|
||||||
})
|
})
|
||||||
@@ -40,6 +41,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -48,6 +50,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<TPasswordFormSchema>({
|
} = useForm<TPasswordFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
|
currentPassword: '',
|
||||||
password: '',
|
password: '',
|
||||||
repeatedPassword: '',
|
repeatedPassword: '',
|
||||||
},
|
},
|
||||||
@@ -56,9 +59,10 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
|
|
||||||
const { mutateAsync: updatePassword } = trpc.profile.updatePassword.useMutation();
|
const { mutateAsync: updatePassword } = trpc.profile.updatePassword.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ password }: TPasswordFormSchema) => {
|
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await updatePassword({
|
await updatePassword({
|
||||||
|
currentPassword,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,6 +96,39 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="current-password" className="text-muted-foreground">
|
||||||
|
Current Password
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="current-password"
|
||||||
|
type={showCurrentPassword ? 'text' : 'password'}
|
||||||
|
minLength={6}
|
||||||
|
maxLength={72}
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="bg-background mt-2 pr-10"
|
||||||
|
{...register('currentPassword')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
type="button"
|
||||||
|
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||||
|
aria-label={showCurrentPassword ? 'Mask password' : 'Reveal password'}
|
||||||
|
onClick={() => setShowCurrentPassword((show) => !show)}
|
||||||
|
>
|
||||||
|
{showCurrentPassword ? (
|
||||||
|
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="text-muted-foreground h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.currentPassword} />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="password" className="text-muted-foreground">
|
<Label htmlFor="password" className="text-muted-foreground">
|
||||||
Password
|
Password
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { FormErrorMessage } from '../form/form-error-message';
|
import { FormErrorMessage } from '../form/form-error-message';
|
||||||
|
|
||||||
export const ZProfileFormSchema = z.object({
|
export const ZProfileFormSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||||
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,7 +117,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
name="signature"
|
name="signature"
|
||||||
render={({ field: { onChange } }) => (
|
render={({ field: { onChange } }) => (
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full rounded-lg border bg-white backdrop-blur-sm dark:border-[#e2d7c5] dark:bg-[#fcf8ee]"
|
className="h-44 w-full"
|
||||||
|
containerClassName="rounded-lg border bg-background"
|
||||||
defaultValue={user.signature ?? undefined}
|
defaultValue={user.signature ?? undefined}
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export const ZSignUpFormSchema = z.object({
|
export const ZSignUpFormSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
password: z.string().min(6).max(72),
|
password: z.string().min(6).max(72),
|
||||||
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
|
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
|
||||||
@@ -147,7 +147,8 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
name="signature"
|
name="signature"
|
||||||
render={({ field: { onChange } }) => (
|
render={({ field: { onChange } }) => (
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="mt-2 h-36 w-full rounded-lg border bg-white dark:border-[#e2d7c5] dark:bg-[#fcf8ee]"
|
className="h-36 w-full"
|
||||||
|
containerClassName="mt-2 rounded-lg border bg-background"
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
export type CopiedValue = string | null;
|
|
||||||
export type CopyFn = (_text: string) => Promise<boolean>;
|
|
||||||
|
|
||||||
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
|
|
||||||
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
|
|
||||||
|
|
||||||
const copy: CopyFn = async (text) => {
|
|
||||||
if (!navigator?.clipboard) {
|
|
||||||
console.warn('Clipboard not supported');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to save to clipboard then save it in the state if worked
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
setCopiedText(text);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Copy failed', error);
|
|
||||||
setCopiedText(null);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return [copiedText, copy];
|
|
||||||
}
|
|
||||||
54
apps/web/src/pages/api/chat/index.ts
Normal file
54
apps/web/src/pages/api/chat/index.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Message, OpenAIStream, StreamingTextResponse } from 'ai';
|
||||||
|
import { Configuration, OpenAIApi } from 'openai-edge';
|
||||||
|
|
||||||
|
import { getContext } from '@documenso/lib/server-only/context';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
const config = new Configuration({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY!,
|
||||||
|
});
|
||||||
|
|
||||||
|
const openai = new OpenAIApi(config);
|
||||||
|
|
||||||
|
export default async function handler(request: Request) {
|
||||||
|
// console.log(request.method);
|
||||||
|
// request.json().then((data) => console.log(data));
|
||||||
|
// return Response.json({ message: 'world' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const lastMessage = data.messages[data.messages.length - 1];
|
||||||
|
const context = await getContext(lastMessage.content);
|
||||||
|
console.log('context', context);
|
||||||
|
const prompt = {
|
||||||
|
role: 'system',
|
||||||
|
content: `AI assistant is a brand new, powerful, human-like artificial intelligence.
|
||||||
|
The traits of AI include expert knowledge, helpfulness, cleverness, and articulateness.
|
||||||
|
AI is a well-behaved and well-mannered individual.
|
||||||
|
AI is always friendly, kind, and inspiring, and he is eager to provide vivid and thoughtful responses to the user.
|
||||||
|
AI has the sum of all knowledge in their brain, and is able to accurately answer nearly any question about any topic in conversation.
|
||||||
|
AI assistant is a big fan of Pinecone and Vercel.
|
||||||
|
START CONTEXT BLOCK
|
||||||
|
${context}
|
||||||
|
END OF CONTEXT BLOCK
|
||||||
|
AI assistant will take into account any CONTEXT BLOCK that is provided in a conversation.
|
||||||
|
If the context does not provide the answer to question, the AI assistant will say, "I'm sorry, but I don't know the answer to that question".
|
||||||
|
AI assistant will not apologize for previous responses, but instead will indicated new information was gained.
|
||||||
|
AI assistant will not invent anything that is not drawn directly from the context.
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
const response = await openai.createChatCompletion({
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
messages: [prompt, ...data.messages.filter((message: Message) => message.role === 'user')],
|
||||||
|
stream: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = OpenAIStream(response);
|
||||||
|
|
||||||
|
return new StreamingTextResponse(stream);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('There was an error getting embeddings: ', error);
|
||||||
|
throw new Error('There was an error getting embeddings');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ export default async function handler(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user && user.Subscription.length > 0) {
|
if (user && user.Subscription) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
|
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
|
||||||
});
|
});
|
||||||
|
|||||||
3
apps/web/src/pages/api/limits/index.ts
Normal file
3
apps/web/src/pages/api/limits/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { limitsHandler } from '@documenso/ee/server-only/limits/handler';
|
||||||
|
|
||||||
|
export default limitsHandler;
|
||||||
@@ -1,197 +1,7 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
import { stripeWebhookHandler } from '@documenso/ee/server-only/stripe/webhook/handler';
|
||||||
|
|
||||||
import { randomBytes } from 'crypto';
|
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { buffer } from 'micro';
|
|
||||||
|
|
||||||
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
|
|
||||||
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
|
|
||||||
import { redis } from '@documenso/lib/server-only/redis';
|
|
||||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import {
|
|
||||||
DocumentDataType,
|
|
||||||
DocumentStatus,
|
|
||||||
FieldType,
|
|
||||||
ReadStatus,
|
|
||||||
SendStatus,
|
|
||||||
SigningStatus,
|
|
||||||
} from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
const log = (...args: unknown[]) => console.log('[stripe]', ...args);
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: { bodyParser: false },
|
api: { bodyParser: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default stripeWebhookHandler;
|
||||||
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
|
|
||||||
// return res.status(500).json({
|
|
||||||
// success: false,
|
|
||||||
// message: 'Subscriptions are not enabled',
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
const sig =
|
|
||||||
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
|
|
||||||
|
|
||||||
if (!sig) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: 'No signature found in request',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
log('constructing body...');
|
|
||||||
const body = await buffer(req);
|
|
||||||
log('constructed body');
|
|
||||||
|
|
||||||
const event = stripe.webhooks.constructEvent(
|
|
||||||
body,
|
|
||||||
sig,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, turbo/no-undeclared-env-vars
|
|
||||||
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET!,
|
|
||||||
);
|
|
||||||
log('event-type:', event.type);
|
|
||||||
|
|
||||||
if (event.type === 'checkout.session.completed') {
|
|
||||||
// This is required since we don't want to create a guard for every event type
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const session = event.data.object as Stripe.Checkout.Session;
|
|
||||||
|
|
||||||
if (session.metadata?.source === 'landing') {
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
id: Number(session.client_reference_id),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: 'User not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const signatureText = session.metadata?.signatureText || user.name;
|
|
||||||
let signatureDataUrl = '';
|
|
||||||
|
|
||||||
if (session.metadata?.signatureDataUrl) {
|
|
||||||
const result = await redis.get<string>(`signature:${session.metadata.signatureDataUrl}`);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
signatureDataUrl = result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
|
|
||||||
|
|
||||||
const { id: documentDataId } = await prisma.documentData.create({
|
|
||||||
data: {
|
|
||||||
type: DocumentDataType.BYTES_64,
|
|
||||||
data: bytes64,
|
|
||||||
initialData: bytes64,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const document = await prisma.document.create({
|
|
||||||
data: {
|
|
||||||
title: 'Documenso Supporter Pledge.pdf',
|
|
||||||
status: DocumentStatus.COMPLETED,
|
|
||||||
userId: user.id,
|
|
||||||
documentDataId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
documentData: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { documentData } = document;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
throw new Error(`Document ${document.id} has no document data`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipient = await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
name: user.name ?? '',
|
|
||||||
email: user.email,
|
|
||||||
token: randomBytes(16).toString('hex'),
|
|
||||||
signedAt: now,
|
|
||||||
readStatus: ReadStatus.OPENED,
|
|
||||||
sendStatus: SendStatus.SENT,
|
|
||||||
signingStatus: SigningStatus.SIGNED,
|
|
||||||
documentId: document.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const field = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
documentId: document.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.SIGNATURE,
|
|
||||||
page: 0,
|
|
||||||
positionX: 77,
|
|
||||||
positionY: 638,
|
|
||||||
inserted: false,
|
|
||||||
customText: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (signatureDataUrl) {
|
|
||||||
documentData.data = await insertImageInPDF(
|
|
||||||
documentData.data,
|
|
||||||
signatureDataUrl,
|
|
||||||
field.positionX.toNumber(),
|
|
||||||
field.positionY.toNumber(),
|
|
||||||
field.page,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
documentData.data = await insertTextInPDF(
|
|
||||||
documentData.data,
|
|
||||||
signatureText ?? '',
|
|
||||||
field.positionX.toNumber(),
|
|
||||||
field.positionY.toNumber(),
|
|
||||||
field.page,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
prisma.signature.create({
|
|
||||||
data: {
|
|
||||||
fieldId: field.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
signatureImageAsBase64: signatureDataUrl || undefined,
|
|
||||||
typedSignature: signatureDataUrl ? '' : signatureText,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.document.update({
|
|
||||||
where: {
|
|
||||||
id: document.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
documentData: {
|
|
||||||
update: {
|
|
||||||
data: documentData.data,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
message: 'Webhook received',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
log('Unhandled webhook event', event.type);
|
|
||||||
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Unhandled webhook event',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,7 +6,3 @@ export default trpcNext.createNextApiHandler({
|
|||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
// res.json({ hello: 'world' });
|
|
||||||
// }
|
|
||||||
|
|||||||
9487
package-lock.json
generated
9487
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -2,6 +2,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
|
"build:web": "turbo run build --filter=@documenso/web",
|
||||||
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
|
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
|
||||||
"start": "cd apps && cd web && next start",
|
"start": "cd apps && cd web && next start",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
@@ -10,9 +11,16 @@
|
|||||||
"commitlint": "commitlint --edit",
|
"commitlint": "commitlint --edit",
|
||||||
"clean": "turbo run clean && rimraf node_modules",
|
"clean": "turbo run clean && rimraf node_modules",
|
||||||
"d": "npm run dx && npm run dev",
|
"d": "npm run dx && npm run dev",
|
||||||
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev -w @documenso/prisma",
|
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev",
|
||||||
"dx:up": "docker compose -f docker/compose-services.yml up -d",
|
"dx:up": "docker compose -f docker/compose-services.yml up -d",
|
||||||
"dx:down": "docker compose -f docker/compose-services.yml down"
|
"dx:down": "docker compose -f docker/compose-services.yml down",
|
||||||
|
"ci": "turbo run build test:e2e",
|
||||||
|
"prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma",
|
||||||
|
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
|
||||||
|
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
|
||||||
|
"prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma",
|
||||||
|
"with:env": "dotenv -e .env -e .env.local --",
|
||||||
|
"reset:hard": "npm run clean && npm i && npm run prisma:generate"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"npm": ">=8.6.0",
|
"npm": ">=8.6.0",
|
||||||
@@ -21,8 +29,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^17.7.1",
|
"@commitlint/cli": "^17.7.1",
|
||||||
"@commitlint/config-conventional": "^17.7.0",
|
"@commitlint/config-conventional": "^17.7.0",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.3.1",
|
||||||
"dotenv-cli": "^7.2.1",
|
"dotenv-cli": "^7.3.0",
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
"eslint-config-custom": "*",
|
"eslint-config-custom": "*",
|
||||||
"husky": "^8.0.0",
|
"husky": "^8.0.0",
|
||||||
@@ -38,6 +46,13 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@pinecone-database/pinecone": "^1.1.1",
|
||||||
|
"@types/md5": "^2.3.4",
|
||||||
|
"ai": "^2.2.16",
|
||||||
|
"langchain": "^0.0.169",
|
||||||
|
"md5": "^2.3.0",
|
||||||
|
"openai-edge": "^1.2.2",
|
||||||
|
"pdf-parse": "^1.1.1",
|
||||||
"recharts": "^2.7.2"
|
"recharts": "^2.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
packages/app-tests/.gitignore
vendored
Normal file
4
packages/app-tests/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/playwright/.cache/
|
||||||
55
packages/app-tests/e2e/test-auth-flow.spec.ts
Normal file
55
packages/app-tests/e2e/test-auth-flow.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { type Page, expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
|
||||||
|
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
/*
|
||||||
|
Using them sequentially so the 2nd test
|
||||||
|
uses the details from the 1st (registration) test
|
||||||
|
*/
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
const username = process.env.E2E_TEST_AUTHENTICATE_USERNAME;
|
||||||
|
const email = process.env.E2E_TEST_AUTHENTICATE_USER_EMAIL;
|
||||||
|
const password = process.env.E2E_TEST_AUTHENTICATE_USER_PASSWORD;
|
||||||
|
|
||||||
|
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
|
||||||
|
await page.goto('/signup');
|
||||||
|
await page.getByLabel('Name').fill(username);
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||||
|
|
||||||
|
const canvas = page.locator('canvas');
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
|
||||||
|
if (box) {
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL('/documents');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user can login with user and password', async ({ page }: { page: Page }) => {
|
||||||
|
await page.goto('/signin');
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
await expect(page).toHaveURL('/documents');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async () => {
|
||||||
|
try {
|
||||||
|
await deleteUser({ email });
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Error deleting user: ${e}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
21
packages/app-tests/package.json
Normal file
21
packages/app-tests/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@documenso/app-tests",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "to-update",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test:dev": "playwright test",
|
||||||
|
"test:e2e": "start-server-and-test \"(cd ../../apps/web && npm run start)\" http://localhost:3000 \"playwright test\""
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.18.1",
|
||||||
|
"@types/node": "^20.8.2",
|
||||||
|
"@documenso/web": "*"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"start-server-and-test": "^2.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
packages/app-tests/playwright.config.ts
Normal file
77
packages/app-tests/playwright.config.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// require('dotenv').config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
// {
|
||||||
|
// name: 'firefox',
|
||||||
|
// use: { ...devices['Desktop Firefox'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
// {
|
||||||
|
// name: 'webkit',
|
||||||
|
// use: { ...devices['Desktop Safari'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: { ...devices['Pixel 5'] },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: { ...devices['iPhone 12'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
// webServer: {
|
||||||
|
// command: 'npm run start',
|
||||||
|
// url: 'http://127.0.0.1:3000',
|
||||||
|
// reuseExistingServer: !process.env.CI,
|
||||||
|
// },
|
||||||
|
});
|
||||||
@@ -14,6 +14,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
"@documenso/prisma": "*"
|
"@documenso/prisma": "*",
|
||||||
|
"luxon": "^3.4.0",
|
||||||
|
"micro": "^10.0.1",
|
||||||
|
"next": "13.5.4",
|
||||||
|
"next-auth": "4.22.3",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"ts-pattern": "^5.0.5",
|
||||||
|
"zod": "^3.21.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
packages/ee/server-only/limits/client.ts
Normal file
31
packages/ee/server-only/limits/client.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { APP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
|
||||||
|
import { FREE_PLAN_LIMITS } from './constants';
|
||||||
|
import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema';
|
||||||
|
|
||||||
|
export type GetLimitsOptions = {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLimits = async ({ headers }: GetLimitsOptions = {}) => {
|
||||||
|
const requestHeaders = headers ?? {};
|
||||||
|
|
||||||
|
const url = new URL(`${APP_BASE_URL}/api/limits`);
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
headers: {
|
||||||
|
...requestHeaders,
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(async (res) => res.json())
|
||||||
|
.then((res) => ZLimitsResponseSchema.parse(res))
|
||||||
|
.catch(() => {
|
||||||
|
return {
|
||||||
|
quota: FREE_PLAN_LIMITS,
|
||||||
|
remaining: FREE_PLAN_LIMITS,
|
||||||
|
} satisfies TLimitsResponseSchema;
|
||||||
|
});
|
||||||
|
};
|
||||||
11
packages/ee/server-only/limits/constants.ts
Normal file
11
packages/ee/server-only/limits/constants.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { TLimitsSchema } from './schema';
|
||||||
|
|
||||||
|
export const FREE_PLAN_LIMITS: TLimitsSchema = {
|
||||||
|
documents: 5,
|
||||||
|
recipients: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
|
||||||
|
documents: Infinity,
|
||||||
|
recipients: Infinity,
|
||||||
|
};
|
||||||
6
packages/ee/server-only/limits/errors.ts
Normal file
6
packages/ee/server-only/limits/errors.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const ERROR_CODES: Record<string, string> = {
|
||||||
|
UNAUTHORIZED: 'You must be logged in to access this resource',
|
||||||
|
USER_FETCH_FAILED: 'An error occurred while fetching your user account',
|
||||||
|
SUBSCRIPTION_FETCH_FAILED: 'An error occurred while fetching your subscription',
|
||||||
|
UNKNOWN: 'An unknown error occurred',
|
||||||
|
};
|
||||||
54
packages/ee/server-only/limits/handler.ts
Normal file
54
packages/ee/server-only/limits/handler.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
import { getToken } from 'next-auth/jwt';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { withStaleWhileRevalidate } from '@documenso/lib/server-only/http/with-swr';
|
||||||
|
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||||
|
|
||||||
|
import { SELFHOSTED_PLAN_LIMITS } from './constants';
|
||||||
|
import { ERROR_CODES } from './errors';
|
||||||
|
import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
|
||||||
|
import { getServerLimits } from './server';
|
||||||
|
|
||||||
|
export const limitsHandler = async (
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse<TLimitsResponseSchema | TLimitsErrorResponseSchema>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const token = await getToken({ req });
|
||||||
|
|
||||||
|
const isBillingEnabled = await getFlag('app_billing');
|
||||||
|
|
||||||
|
if (!isBillingEnabled) {
|
||||||
|
return withStaleWhileRevalidate<typeof res>(res).status(200).json({
|
||||||
|
quota: SELFHOSTED_PLAN_LIMITS,
|
||||||
|
remaining: SELFHOSTED_PLAN_LIMITS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token?.email) {
|
||||||
|
throw new Error(ERROR_CODES.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limits = await getServerLimits({ email: token.email });
|
||||||
|
|
||||||
|
return withStaleWhileRevalidate<typeof res>(res).status(200).json(limits);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('error', err);
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
const status = match(err.message)
|
||||||
|
.with(ERROR_CODES.UNAUTHORIZED, () => 401)
|
||||||
|
.otherwise(() => 500);
|
||||||
|
|
||||||
|
return res.status(status).json({
|
||||||
|
error: ERROR_CODES[err.message] ?? ERROR_CODES.UNKNOWN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
error: ERROR_CODES.UNKNOWN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
53
packages/ee/server-only/limits/provider/client.tsx
Normal file
53
packages/ee/server-only/limits/provider/client.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { getLimits } from '../client';
|
||||||
|
import { FREE_PLAN_LIMITS } from '../constants';
|
||||||
|
import { TLimitsResponseSchema } from '../schema';
|
||||||
|
|
||||||
|
export type LimitsContextValue = TLimitsResponseSchema;
|
||||||
|
|
||||||
|
const LimitsContext = createContext<LimitsContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useLimits = () => {
|
||||||
|
const limits = useContext(LimitsContext);
|
||||||
|
|
||||||
|
if (!limits) {
|
||||||
|
throw new Error('useLimits must be used within a LimitsProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return limits;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LimitsProviderProps = {
|
||||||
|
initialValue?: LimitsContextValue;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => {
|
||||||
|
const defaultValue: TLimitsResponseSchema = {
|
||||||
|
quota: FREE_PLAN_LIMITS,
|
||||||
|
remaining: FREE_PLAN_LIMITS,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [limits, setLimits] = useState(() => initialValue ?? defaultValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void getLimits().then((limits) => setLimits(limits));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onFocus = () => {
|
||||||
|
void getLimits().then((limits) => setLimits(limits));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('focus', onFocus);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('focus', onFocus);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <LimitsContext.Provider value={limits}>{children}</LimitsContext.Provider>;
|
||||||
|
};
|
||||||
18
packages/ee/server-only/limits/provider/server.tsx
Normal file
18
packages/ee/server-only/limits/provider/server.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
import { getLimits } from '../client';
|
||||||
|
import { LimitsProvider as ClientLimitsProvider } from './client';
|
||||||
|
|
||||||
|
export type LimitsProviderProps = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LimitsProvider = async ({ children }: LimitsProviderProps) => {
|
||||||
|
const requestHeaders = Object.fromEntries(headers().entries());
|
||||||
|
|
||||||
|
const limits = await getLimits({ headers: requestHeaders });
|
||||||
|
|
||||||
|
return <ClientLimitsProvider initialValue={limits}>{children}</ClientLimitsProvider>;
|
||||||
|
};
|
||||||
28
packages/ee/server-only/limits/schema.ts
Normal file
28
packages/ee/server-only/limits/schema.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Not proud of the below but it's a way to deal with Infinity when returning JSON.
|
||||||
|
export const ZLimitsSchema = z.object({
|
||||||
|
documents: z
|
||||||
|
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
|
||||||
|
.optional()
|
||||||
|
.default(0),
|
||||||
|
recipients: z
|
||||||
|
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
|
||||||
|
.optional()
|
||||||
|
.default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TLimitsSchema = z.infer<typeof ZLimitsSchema>;
|
||||||
|
|
||||||
|
export const ZLimitsResponseSchema = z.object({
|
||||||
|
quota: ZLimitsSchema,
|
||||||
|
remaining: ZLimitsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TLimitsResponseSchema = z.infer<typeof ZLimitsResponseSchema>;
|
||||||
|
|
||||||
|
export const ZLimitsErrorResponseSchema = z.object({
|
||||||
|
error: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TLimitsErrorResponseSchema = z.infer<typeof ZLimitsErrorResponseSchema>;
|
||||||
78
packages/ee/server-only/limits/server.ts
Normal file
78
packages/ee/server-only/limits/server.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
|
||||||
|
import { ERROR_CODES } from './errors';
|
||||||
|
import { ZLimitsSchema } from './schema';
|
||||||
|
|
||||||
|
export type GetServerLimitsOptions = {
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||||
|
const isBillingEnabled = await getFlag('app_billing');
|
||||||
|
|
||||||
|
if (!isBillingEnabled) {
|
||||||
|
return {
|
||||||
|
quota: SELFHOSTED_PLAN_LIMITS,
|
||||||
|
remaining: SELFHOSTED_PLAN_LIMITS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw new Error(ERROR_CODES.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Subscription: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
let quota = structuredClone(FREE_PLAN_LIMITS);
|
||||||
|
let remaining = structuredClone(FREE_PLAN_LIMITS);
|
||||||
|
|
||||||
|
if (user.Subscription?.priceId) {
|
||||||
|
const { product } = await stripe.prices
|
||||||
|
.retrieve(user.Subscription.priceId, {
|
||||||
|
expand: ['product'],
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof product === 'string') {
|
||||||
|
throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {});
|
||||||
|
remaining = structuredClone(quota);
|
||||||
|
}
|
||||||
|
|
||||||
|
const documents = await prisma.document.count({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
createdAt: {
|
||||||
|
gte: DateTime.utc().startOf('month').toJSDate(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
remaining.documents = Math.max(remaining.documents - documents, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
quota,
|
||||||
|
remaining,
|
||||||
|
};
|
||||||
|
};
|
||||||
32
packages/ee/server-only/stripe/get-checkout-session.ts
Normal file
32
packages/ee/server-only/stripe/get-checkout-session.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
|
||||||
|
export type GetCheckoutSessionOptions = {
|
||||||
|
customerId: string;
|
||||||
|
priceId: string;
|
||||||
|
returnUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCheckoutSession = async ({
|
||||||
|
customerId,
|
||||||
|
priceId,
|
||||||
|
returnUrl,
|
||||||
|
}: GetCheckoutSessionOptions) => {
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
mode: 'subscription',
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: priceId,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
success_url: `${returnUrl}?success=true`,
|
||||||
|
cancel_url: `${returnUrl}?canceled=true`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return session.url;
|
||||||
|
};
|
||||||
19
packages/ee/server-only/stripe/get-customer.ts
Normal file
19
packages/ee/server-only/stripe/get-customer.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
|
||||||
|
export const getStripeCustomerByEmail = async (email: string) => {
|
||||||
|
const foundStripeCustomers = await stripe.customers.list({
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
|
||||||
|
return foundStripeCustomers.data[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStripeCustomerById = async (stripeCustomerId: string) => {
|
||||||
|
try {
|
||||||
|
const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId);
|
||||||
|
|
||||||
|
return !stripeCustomer.deleted ? stripeCustomer : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
49
packages/ee/server-only/stripe/get-prices-by-interval.ts
Normal file
49
packages/ee/server-only/stripe/get-prices-by-interval.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
|
||||||
|
// Utility type to handle usage of the `expand` option.
|
||||||
|
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
|
||||||
|
|
||||||
|
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
|
||||||
|
|
||||||
|
export const getPricesByInterval = async () => {
|
||||||
|
let { data: prices } = await stripe.prices.search({
|
||||||
|
query: `active:'true' type:'recurring'`,
|
||||||
|
expand: ['data.product'],
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
prices = prices.filter((price) => {
|
||||||
|
// We use `expand` to get the product, but it's not typed as part of the Price type.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const product = price.product as Stripe.Product;
|
||||||
|
|
||||||
|
// Filter out prices for products that are not active.
|
||||||
|
return product.active;
|
||||||
|
});
|
||||||
|
|
||||||
|
const intervals: PriceIntervals = {
|
||||||
|
day: [],
|
||||||
|
week: [],
|
||||||
|
month: [],
|
||||||
|
year: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add each price to the correct interval.
|
||||||
|
for (const price of prices) {
|
||||||
|
if (price.recurring?.interval) {
|
||||||
|
// We use `expand` to get the product, but it's not typed as part of the Price type.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
intervals[price.recurring.interval].push(price as PriceWithProduct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order all prices by unit_amount.
|
||||||
|
intervals.day.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||||
|
intervals.week.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||||
|
intervals.month.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||||
|
intervals.year.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||||
|
|
||||||
|
return intervals;
|
||||||
|
};
|
||||||
17
packages/ee/server-only/stripe/get-product-by-price-id.ts
Normal file
17
packages/ee/server-only/stripe/get-product-by-price-id.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
|
||||||
|
export type GetProductByPriceIdOptions = {
|
||||||
|
priceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProductByPriceId = async ({ priceId }: GetProductByPriceIdOptions) => {
|
||||||
|
const { product } = await stripe.prices.retrieve(priceId, {
|
||||||
|
expand: ['product'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof product === 'string' || 'deleted' in product) {
|
||||||
|
throw new Error('Product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return product;
|
||||||
|
};
|
||||||
224
packages/ee/server-only/stripe/webhook/handler.ts
Normal file
224
packages/ee/server-only/stripe/webhook/handler.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
import { buffer } from 'micro';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { onSubscriptionDeleted } from './on-subscription-deleted';
|
||||||
|
import { onSubscriptionUpdated } from './on-subscription-updated';
|
||||||
|
|
||||||
|
type StripeWebhookResponse = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stripeWebhookHandler = async (
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse<StripeWebhookResponse>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const isBillingEnabled = await getFlag('app_billing');
|
||||||
|
|
||||||
|
if (!isBillingEnabled) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Billing is disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature =
|
||||||
|
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'No signature found in request',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await buffer(req);
|
||||||
|
|
||||||
|
const event = stripe.webhooks.constructEvent(
|
||||||
|
body,
|
||||||
|
signature,
|
||||||
|
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET,
|
||||||
|
);
|
||||||
|
|
||||||
|
await match(event.type)
|
||||||
|
.with('checkout.session.completed', async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
|
||||||
|
const userId = Number(session.client_reference_id);
|
||||||
|
const subscriptionId =
|
||||||
|
typeof session.subscription === 'string'
|
||||||
|
? session.subscription
|
||||||
|
: session.subscription?.id;
|
||||||
|
|
||||||
|
if (!subscriptionId || Number.isNaN(userId)) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid session',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
|
await onSubscriptionUpdated({ userId, subscription });
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Webhook received',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.with('customer.subscription.updated', async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
|
||||||
|
const customerId =
|
||||||
|
typeof subscription.customer === 'string'
|
||||||
|
? subscription.customer
|
||||||
|
: subscription.customer.id;
|
||||||
|
|
||||||
|
const result = await prisma.subscription.findFirst({
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.userId) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Webhook received',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.with('invoice.payment_succeeded', async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
|
||||||
|
if (invoice.billing_reason !== 'subscription_cycle') {
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Webhook received',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerId =
|
||||||
|
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
||||||
|
|
||||||
|
const subscriptionId =
|
||||||
|
typeof invoice.subscription === 'string'
|
||||||
|
? invoice.subscription
|
||||||
|
: invoice.subscription?.id;
|
||||||
|
|
||||||
|
if (!customerId || !subscriptionId) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid invoice',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
|
const result = await prisma.subscription.findFirst({
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.userId) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Webhook received',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.with('invoice.payment_failed', async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
|
||||||
|
const customerId =
|
||||||
|
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
||||||
|
|
||||||
|
const subscriptionId =
|
||||||
|
typeof invoice.subscription === 'string'
|
||||||
|
? invoice.subscription
|
||||||
|
: invoice.subscription?.id;
|
||||||
|
|
||||||
|
if (!customerId || !subscriptionId) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid invoice',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
|
const result = await prisma.subscription.findFirst({
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.userId) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||||
|
})
|
||||||
|
.with('customer.subscription.deleted', async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
|
||||||
|
await onSubscriptionDeleted({ subscription });
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Webhook received',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.otherwise(() => {
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Webhook received',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type OnSubscriptionDeletedOptions = {
|
||||||
|
subscription: Stripe.Subscription;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
|
||||||
|
const customerId =
|
||||||
|
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
|
||||||
|
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: SubscriptionStatus.INACTIVE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type OnSubscriptionUpdatedOptions = {
|
||||||
|
userId: number;
|
||||||
|
subscription: Stripe.Subscription;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onSubscriptionUpdated = async ({
|
||||||
|
userId,
|
||||||
|
subscription,
|
||||||
|
}: OnSubscriptionUpdatedOptions) => {
|
||||||
|
const customerId =
|
||||||
|
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
|
||||||
|
|
||||||
|
const status = match(subscription.status)
|
||||||
|
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||||
|
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||||
|
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||||
|
|
||||||
|
await prisma.subscription.upsert({
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
customerId,
|
||||||
|
status: status,
|
||||||
|
planId: subscription.id,
|
||||||
|
priceId: subscription.items.data[0].price.id,
|
||||||
|
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
customerId,
|
||||||
|
status: status,
|
||||||
|
planId: subscription.id,
|
||||||
|
priceId: subscription.items.data[0].price.id,
|
||||||
|
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1 +1 @@
|
|||||||
export { render, renderAsync } from '@react-email/components';
|
export { render } from '@react-email/components';
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
assetBaseUrl = 'http://localhost:3002',
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
customBody,
|
customBody,
|
||||||
}: DocumentInviteEmailTemplateProps) => {
|
}: DocumentInviteEmailTemplateProps) => {
|
||||||
const previewText = `Completed Document`;
|
const previewText = `${inviterName} has invited you to sign ${documentName}`;
|
||||||
|
|
||||||
const getAssetUrl = (path: string) => {
|
const getAssetUrl = (path: string) => {
|
||||||
return new URL(path, assetBaseUrl).toString();
|
return new URL(path, assetBaseUrl).toString();
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
"clean": "rimraf node_modules"
|
"clean": "rimraf node_modules"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "^5.59.2",
|
"@typescript-eslint/eslint-plugin": "6.8.0",
|
||||||
"@typescript-eslint/parser": "^5.59.2",
|
"@typescript-eslint/parser": "6.8.0",
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
"eslint-config-next": "13.4.19",
|
"eslint-config-next": "13.4.19",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
@@ -16,6 +16,6 @@
|
|||||||
"eslint-plugin-package-json": "^0.1.4",
|
"eslint-plugin-package-json": "^0.1.4",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-react": "^7.32.2",
|
"eslint-plugin-react": "^7.32.2",
|
||||||
"typescript": "^5.1.6"
|
"typescript": "5.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
53
packages/lib/client-only/hooks/use-copy-share-link.ts
Normal file
53
packages/lib/client-only/hooks/use-copy-share-link.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema';
|
||||||
|
|
||||||
|
import { useCopyToClipboard } from './use-copy-to-clipboard';
|
||||||
|
|
||||||
|
export type UseCopyShareLinkOptions = {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onError?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCopyShareLink({ onSuccess, onError }: UseCopyShareLinkOptions) {
|
||||||
|
const [, copyToClipboard] = useCopyToClipboard();
|
||||||
|
|
||||||
|
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
|
||||||
|
trpc.shareLink.createOrGetShareLink.useMutation();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a newly created, or pre-existing share link to the user's clipboard.
|
||||||
|
*
|
||||||
|
* @param payload The payload to create or get a share link.
|
||||||
|
*/
|
||||||
|
const createAndCopyShareLink = async (payload: TCreateOrGetShareLinkMutationSchema) => {
|
||||||
|
const valueToCopy = createOrGetShareLink(payload).then(
|
||||||
|
(result) => `${window.location.origin}/share/${result.slug}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await copyShareLink(valueToCopy);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a share link to the user's clipboard.
|
||||||
|
*
|
||||||
|
* @param shareLink Either the share link itself or a promise that returns a shared link.
|
||||||
|
*/
|
||||||
|
const copyShareLink = async (shareLink: Promise<string> | string) => {
|
||||||
|
try {
|
||||||
|
const isCopySuccess = await copyToClipboard(shareLink);
|
||||||
|
if (!isCopySuccess) {
|
||||||
|
throw new Error('Copy to clipboard failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (e) {
|
||||||
|
onError?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createAndCopyShareLink,
|
||||||
|
copyShareLink,
|
||||||
|
isCopyingShareLink: isCreatingShareLink,
|
||||||
|
};
|
||||||
|
}
|
||||||
60
packages/lib/client-only/hooks/use-copy-to-clipboard.ts
Normal file
60
packages/lib/client-only/hooks/use-copy-to-clipboard.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export type CopiedValue = string | null;
|
||||||
|
export type CopyFn = (_text: CopyValue, _blobType?: string) => Promise<boolean>;
|
||||||
|
|
||||||
|
type CopyValue = Promise<string> | string;
|
||||||
|
|
||||||
|
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
|
||||||
|
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
|
||||||
|
|
||||||
|
const copy: CopyFn = async (text, blobType = 'text/plain') => {
|
||||||
|
if (!navigator?.clipboard) {
|
||||||
|
console.warn('Clipboard not supported');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isClipboardApiSupported = Boolean(typeof ClipboardItem && navigator.clipboard.write);
|
||||||
|
|
||||||
|
// Try to save to clipboard then save it in the state if worked
|
||||||
|
try {
|
||||||
|
isClipboardApiSupported
|
||||||
|
? await handleClipboardApiCopy(text, blobType)
|
||||||
|
: await handleWriteTextCopy(text);
|
||||||
|
|
||||||
|
setCopiedText(await text);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Copy failed', error);
|
||||||
|
setCopiedText(null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle copying values to the clipboard using the ClipboardItem API.
|
||||||
|
*
|
||||||
|
* Works in all browsers except FireFox.
|
||||||
|
*
|
||||||
|
* https://caniuse.com/mdn-api_clipboarditem
|
||||||
|
*/
|
||||||
|
const handleClipboardApiCopy = async (value: CopyValue, blobType = 'text/plain') => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ [blobType]: value })]);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback attempt.
|
||||||
|
await handleWriteTextCopy(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle copying values to the clipboard using `writeText`.
|
||||||
|
*
|
||||||
|
* Works in all browsers except Safari for async values.
|
||||||
|
*/
|
||||||
|
const handleWriteTextCopy = async (value: CopyValue) => {
|
||||||
|
await navigator.clipboard.writeText(await value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [copiedText, copy];
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user