Compare commits

..

3 Commits

Author SHA1 Message Date
Mythie
6e596ce7f5 Merge branch 'main' into release 2023-11-09 15:52:12 +11:00
Lucas Smith
b41b026e95 Merge pull request #222 from documenso/main
Release 0.9.2
2023-07-08 19:10:17 +10:00
Lucas Smith
f4e6aca4cf Merge pull request #218 from documenso/main
Release 0.9.1
2023-06-28 08:21:16 +10:00
417 changed files with 6255 additions and 71060 deletions

View File

@@ -2,11 +2,6 @@
NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="secret" NEXTAUTH_SECRET="secret"
# [[CRYPTO]]
# Application Key for symmetric encryption and decryption
# This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
# [[AUTH OPTIONAL]] # [[AUTH OPTIONAL]]
NEXT_PRIVATE_GOOGLE_CLIENT_ID="" NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET="" NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
@@ -29,15 +24,15 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
NEXT_PUBLIC_UPLOAD_TRANSPORT="database" NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers. # OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002" NEXT_PRIVATE_UPLOAD_ENDPOINT=
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1. # OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
NEXT_PRIVATE_UPLOAD_REGION="unknown" NEXT_PRIVATE_UPLOAD_REGION=
# REQUIRED: Defines the bucket to use for the S3 storage transport. # REQUIRED: Defines the bucket to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_BUCKET="documenso" NEXT_PRIVATE_UPLOAD_BUCKET=
# OPTIONAL: Defines the access key ID to use for the S3 storage transport. # OPTIONAL: Defines the access key ID to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID="documenso" NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=
# OPTIONAL: Defines the secret access key to use for the S3 storage transport. # OPTIONAL: Defines the secret access key to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password" NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
# [[SMTP]] # [[SMTP]]
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels # OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
@@ -77,14 +72,16 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
NEXT_PRIVATE_STRIPE_API_KEY= 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_FREE_PLAN_ID=
# [[FEATURES]] # [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags. # OPTIONAL: Leave blank to disable PostHog and feature flags.
NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_POSTHOG_KEY=""
# OPTIONAL: Defines the host to use for PostHog.
NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com"
# OPTIONAL: Leave blank to disable billing. # OPTIONAL: Leave blank to disable billing.
NEXT_PUBLIC_FEATURE_BILLING_ENABLED= NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Leave blank to allow users to signup through /signup page.
NEXT_PUBLIC_DISABLE_SIGNUP=
# This is only required for the marketing site # This is only required for the marketing site
# [[REDIS]] # [[REDIS]]

View File

@@ -1,10 +1,11 @@
name: 'Bug Report' name: "Bug Report"
labels: ['bug'] labels: ["bug"]
description: Create a bug report to help us improve description: Create a bug report to help us improve
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: Thank you for reporting an issue. value:
Thank you for reporting an issue.
Please fill in as much of the form below as you're able to. Please fill in as much of the form below as you're able to.
- type: textarea - type: textarea
attributes: attributes:

View File

@@ -1,4 +1,4 @@
name: 'Feature Request' name: "Feature Request"
description: Suggest a new idea or enhancement for this project description: Suggest a new idea or enhancement for this project
body: body:
- type: markdown - type: markdown

View File

@@ -1,4 +1,4 @@
name: 'General Improvement' name: "General Improvement"
description: Suggest a minor enhancement or improvement for this project description: Suggest a minor enhancement or improvement for this project
body: body:
- type: markdown - type: markdown

View File

@@ -4,29 +4,29 @@ updates:
- package-ecosystem: 'github-actions' - package-ecosystem: 'github-actions'
directory: '/' directory: '/'
schedule: schedule:
interval: 'weekly' interval: "weekly"
target-branch: 'main' target-branch: "main"
labels: labels:
- 'ci dependencies' - "ci dependencies"
- 'ci' - "ci"
open-pull-requests-limit: 0 open-pull-requests-limit: 0
- package-ecosystem: 'npm' - package-ecosystem: "npm"
directory: '/apps/marketing' directory: "/apps/marketing"
schedule: schedule:
interval: 'weekly' interval: "weekly"
target-branch: 'main' target-branch: "main"
labels: labels:
- 'npm dependencies' - "npm dependencies"
- 'frontend' - "frontend"
open-pull-requests-limit: 0 open-pull-requests-limit: 0
- package-ecosystem: 'npm' - package-ecosystem: "npm"
directory: '/apps/web' directory: "/apps/web"
schedule: schedule:
interval: 'weekly' interval: "weekly"
target-branch: 'main' target-branch: "main"
labels: labels:
- 'npm dependencies' - "npm dependencies"
- 'frontend' - "frontend"
open-pull-requests-limit: 0 open-pull-requests-limit: 0

21
.github/labeler.yml vendored
View File

@@ -1,21 +0,0 @@
'apps: marketing':
- apps/marketing/**
'apps: web':
- apps/web/**
'version bump 👀':
- '**/package.json'
- '**/package-lock.json'
'🚨 migrations 🚨':
- packages/prisma/migrations/**/migration.sql
'🚨 e2e changes 🚨':
- packages/app-tests/e2e/**
'🚨 .env changes 🚨':
- .env.example
'pkg: ee changes':
- packages/ee/**

View File

@@ -1,10 +1,10 @@
name: 'Continuous Integration' name: "Continuous Integration"
on: on:
push: push:
branches: ['main'] branches: [ "main" ]
pull_request: pull_request:
branches: ['main'] branches: [ "main" ]
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
@@ -14,17 +14,17 @@ env:
HUSKY: 0 HUSKY: 0
jobs: jobs:
build_app: build:
name: Build App name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: 18 node-version: 18
cache: npm cache: npm
@@ -37,15 +37,3 @@ jobs:
- name: Build - name: Build
run: npm run build run: npm run build
build_docker:
name: Build Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Build Docker Image
run: ./docker/build.sh

View File

@@ -1,11 +1,11 @@
name: 'CodeQL' name: "CodeQL"
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: ['main'] branches: [ "main" ]
pull_request: pull_request:
branches: ['main'] branches: [ "main" ]
jobs: jobs:
analyze: analyze:
@@ -23,9 +23,9 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v3
- uses: actions/setup-node@v4 - uses: actions/setup-node@v3
with: with:
node-version: 18 node-version: 18
cache: npm cache: npm

View File

@@ -1,24 +0,0 @@
name: Deploy to Production
on:
push:
tags:
- '*'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GH_TOKEN }}
- name: Push to release branch
run: |
git checkout release || git checkout -b release
git merge --ff-only main
git push origin release

View File

@@ -1,50 +1,51 @@
name: Playwright Tests name: Playwright Tests
on: on:
push: push:
branches: ['main'] branches: [ "main" ]
pull_request: pull_request:
branches: ['main'] branches: [ "main" ]
jobs: jobs:
e2e_tests: e2e_tests:
name: "E2E Tests"
timeout-minutes: 60 timeout-minutes: 60
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- uses: actions/setup-node@v4 - uses: actions/setup-node@v3
with: with:
node-version: 18 node-version: 18
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Copy env - name: Copy env
run: cp .env.example .env run: cp .env.example .env
- name: Start Services
run: npm run dx:up
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: npx playwright install --with-deps run: npx playwright install --with-deps
- name: Generate Prisma Client - name: Generate Prisma Client
run: npm run prisma:generate -w @documenso/prisma run: npm run prisma:generate -w @documenso/prisma
- name: Create the database - name: Create the database
run: npm run prisma:migrate-dev run: npm run prisma:migrate-dev
- name: Seed the database
run: npm run prisma:seed
- name: Run Playwright tests - name: Run Playwright tests
run: npm run ci run: npm run ci
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: test-results name: playwright-report
path: "packages/app-tests/**/test-results/*" path: playwright-report/
retention-days: 30 retention-days: 30
env: env:
NEXT_PRIVATE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }} TURBO_TEAM: ${{ vars.TURBO_TEAM }}

View File

@@ -1,29 +0,0 @@
name: 'Welcome New Contributors'
on:
pull_request:
types: ['opened']
issues:
types: ['opened']
permissions:
pull-requests: write
issues: write
jobs:
welcome-message:
name: Welcome Contributors
if: github.event.action == 'opened'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |
Thank you for creating your first Pull Request and for being a part of the open signing revolution! 💚🚀
<br /> Feel free to hop into our community in [Discord](https://documen.so/discord)
issue-message: |
Thank you for opening your first issue and for being a part of the open signing revolution!
<br /> One of our team members will review it and get back to you as soon as it possible 💚
<br /> Meanwhile, please feel free to hop into our community in [Discord](https://documen.so/discord)

View File

@@ -1,63 +0,0 @@
name: 'Issue Assignee Check'
on:
issues:
types: ['assigned']
permissions:
issues: write
jobs:
countIssues:
if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: npm
- name: Install Octokit
run: npm install @octokit/rest@18
- name: Check Assigned User's Issue Count
id: parse-comment
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { Octokit } = require("@octokit/rest");
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const username = context.payload.issue.assignee.login;
console.log(`Username Extracted: ${username}`);
const { data: issues } = await octokit.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
assignee: username,
state: 'open'
});
const issueCount = issues.length;
console.log(`Issue Count For ${username}: ${issueCount}`);
if (issueCount > 3) {
let issueCountMessage = `### 🚨 Documenso Police 🚨`;
issueCountMessage += `\n@${username} has ${issueCount} open issues assigned already. Consider whether this issue should be assigned to them or left open for another contributor.`;
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: issueCountMessage,
headers: {
'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
}
});
}

View File

@@ -1,21 +0,0 @@
name: 'Label Issues'
on:
issues:
types: ['opened', 'reopened']
jobs:
label_issues:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/github-script@v6
with:
script: |
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ["needs triage"]
})

View File

@@ -1,20 +0,0 @@
name: 'PR Labeler'
on:
- pull_request_target
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
labeler:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v4
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
sync-labels: ''

View File

@@ -1,64 +0,0 @@
name: 'PR Review Reminder'
on:
pull_request:
types: ['opened', 'reopened', 'ready_for_review', 'review_requested']
permissions:
pull-requests: write
jobs:
checkPRs:
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: npm
- name: Install Octokit
run: npm install @octokit/rest@18
- name: Check user's PRs awaiting review
id: parse-prs
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { Octokit } = require("@octokit/rest");
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const username = context.payload.pull_request.user.login;
console.log(`Username Extracted: ${username}`);
const { data: pullRequests } = await octokit.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'created',
direction: 'asc',
});
const userPullRequests = pullRequests.filter(pr => pr.user.login === username && (pr.state === 'open' || pr.state === 'ready_for_review'));
const prCount = userPullRequests.length;
console.log(`PR Count for ${username}: ${prCount}`);
if (prCount > 3) {
let prReminderMessage = `🚨 @${username} has ${prCount} pull requests awaiting review. Please consider reviewing them when possible. 🚨`;
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: prReminderMessage,
headers: {
'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
}
});
}

View File

@@ -1,4 +1,4 @@
name: 'Validate PR Name' name: "Validate PR Name"
on: on:
pull_request_target: pull_request_target:
@@ -9,54 +9,13 @@ on:
- synchronize - synchronize
permissions: permissions:
pull-requests: write pull-requests: read
jobs: jobs:
validate-pr: validate-pr:
name: Validate PR title name: Validate PR title
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check PR creator's previous activity
id: check_activity
run: |
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
ACTIVITY=$(curl -s "https://api.github.com/search/commits?q=author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
if [ "$ACTIVITY" -eq 0 ]; then
echo "::set-output name=is_new::true"
else
echo "::set-output name=is_new::false"
fi
- name: Count PRs created by user
id: count_prs
run: |
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
PR_COUNT=$(curl -s "https://api.github.com/search/issues?q=type:pr+is:open+author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
echo "::set-output name=pr_count::$PR_COUNT"
- uses: amannn/action-semantic-pull-request@v5 - uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@v2
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey There! and thank you for opening this pull request! 📝👋🏼
We require pull request titles to follow the [Conventional Commits Spec](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
Details:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null && steps.check_activity.outputs.is_new == 'false' && steps.count_prs.outputs.pr_count < 2}}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions for pull request titles! 💚🚀

View File

@@ -1,24 +0,0 @@
name: 'Mark Stale Issues and PRs'
on:
schedule:
- cron: '0 */8 * * *'
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v4
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-pr-stale: 90
days-before-issue-stale: 90
days-before-issue-close: 180
stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.'
close-pr-message: 'This PR has been closed because of inactivity.'
exempt-pr-labels: 'WIP,on-hold,needs review'
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned,needs triage'

View File

@@ -1,16 +0,0 @@
node_modules
.next
public
**/**/node_modules
**/**/.next
**/**/public
*.lock
*.log
*.test.ts
.gitignore
.npmignore
.prettierignore
.DS_Store
.eslintignore

View File

@@ -1,13 +1,10 @@
{ {
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit" "source.fixAll.eslint": true
}, },
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"], "eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
"javascript.preferences.importModuleSpecifier": "non-relative", "javascript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.useAliasesForRenames": false, "javascript.preferences.useAliasesForRenames": false,
"typescript.enablePromptUseWorkspaceTsdk": true, "typescript.enablePromptUseWorkspaceTsdk": true
"files.eol": "\n",
"editor.tabSize": 2,
"editor.insertSpaces": true
} }

View File

@@ -13,9 +13,9 @@
· ·
<a href="https://github.com/documenso/documenso/issues">Issues</a> <a href="https://github.com/documenso/documenso/issues">Issues</a>
· ·
<a href="https://documen.so/live">Upcoming Releases</a> <a href="https://github.com/documenso/documenso/milestones">Roadmap</a>
· ·
<a href="https://documen.so/roadmap">Roadmap</a> <a href="https://documen.so/launches">Upcoming Launches</a>
</p> </p>
</p> </p>
@@ -115,12 +115,10 @@ To run Documenso locally, you will need
Want to get up and running quickly? Follow these steps: Want to get up and running quickly? Follow these steps:
1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account. 1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
After forking the repository, clone it to your local device by using the following command:
```sh ```sh
git clone https://github.com/<your-username>/documenso git clone https://github.com/documenso/documenso
``` ```
2. Set up your `.env` file using the recommendations in the `.env.example` file. Alternatively, just run `cp .env.example .env` to get started with our handpicked defaults. 2. Set up your `.env` file using the recommendations in the `.env.example` file. Alternatively, just run `cp .env.example .env` to get started with our handpicked defaults.
@@ -141,25 +139,21 @@ npm run d
1. **App** - http://localhost:3000 1. **App** - http://localhost:3000
2. **Incoming Mail Access** - http://localhost:9000 2. **Incoming Mail Access** - http://localhost:9000
3. **Database Connection Details**
3. **Database Connection Details**
- **Port**: 54320 - **Port**: 54320
- **Connection**: Use your favorite database client to connect using the provided port. - **Connection**: Use your favorite database client to connect using the provided port.
4. **S3 Storage Dashboard** - http://localhost:9001
## Developer Setup ## Developer Setup
### Manual Setup ### Manual Setup
Follow these steps to setup Documenso on your local machine: Follow these steps to setup Documenso on your local machine:
1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account. 1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
After forking the repository, clone it to your local device by using the following command:
```sh ```sh
git clone https://github.com/<your-username>/documenso git clone https://github.com/documenso/documenso
``` ```
2. Run `npm i` in the root directory 2. Run `npm i` in the root directory
@@ -199,12 +193,6 @@ git clone https://github.com/<your-username>/documenso
We support DevContainers for VSCode. [Click here to get started.](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/documenso/documenso) We support DevContainers for VSCode. [Click here to get started.](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/documenso/documenso)
### Video walkthrough
If you're a visual learner and prefer to watch a video walkthrough of setting up Documenso locally, check out this video:
[![Watch the video](https://img.youtube.com/vi/Y0ppIQrEnZs/hqdefault.jpg)](https://youtu.be/Y0ppIQrEnZs)
## Docker ## Docker
🚧 Docker containers and images are current in progress. We are actively working on bringing a simple Docker build and publish pipeline for Documenso. 🚧 Docker containers and images are current in progress. We are actively working on bringing a simple Docker build and publish pipeline for Documenso.
@@ -246,7 +234,7 @@ Now you can install the dependencies and build it:
``` ```
npm i npm i
npm run build:web npm run:build:web
npm run prisma:migrate-deploy npm run prisma:migrate-deploy
``` ```
@@ -284,16 +272,12 @@ WantedBy=multi-user.target
### Railway ### Railway
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p) [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/DjrRRX)
### Render ### Render
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso) [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso)
### Koyeb
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
## Troubleshooting ## Troubleshooting
### I'm not receiving any emails when using the developer quickstart. ### I'm not receiving any emails when using the developer quickstart.

View File

@@ -1,6 +1,6 @@
--- ---
title: Announcing Pre-Seed and Open Metrics title: Announcing Pre-Seed and Open Metrics
description: We are excited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso. description: We are exicited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
authorName: 'Timur Ercan' authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg' authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder' authorRole: 'Co-Founder'
@@ -14,11 +14,9 @@ tags:
Today I'm happy to announce that we closed a \$1.25M Pre-Seed round for Documenso, bringing our total funding to \$1.54M. The round actually closed last month, we just were sneaky about it. Today I'm happy to announce that we closed a \$1.25M Pre-Seed round for Documenso, bringing our total funding to \$1.54M. The round actually closed last month, we just were sneaky about it.
## Two more for the road (to open signing) ## Two more for the road (to open signing)
We're ecstatic to welcome [OSS Capital](https://twitter.com/osscapital) and especially [Joseph Jacks](https://twitter.com/JosephJacks_) to the inner circle of the open signing revolution. We're also fortunate to be joined by Orrick's very own [John Harrison](https://www.linkedin.com/in/john-harrison-a1213b9/) and his legal experience. For those who are wondering, yes, the round was, of course, signed using Documenso. We're ecstatic to welcome [OSS Capital](https://twitter.com/osscapital) and especially [Joseph Jacks](https://twitter.com/JosephJacks_) to the inner circle of the open signing revolution. We're also fortunate to be joined by Orrick's very own [John Harrison](https://www.linkedin.com/in/john-harrison-a1213b9/) and his legal experience. For those who are wondering, yes, the round was, of course, signed using Documenso.
## Open Source, Open Metrics ## Open Source, Open Metrics
If you follow us, you know we're firmly committed to the open source values of openness and transparency. For us, this includes not only the code side of things but also the business. As we aim to build trust among our investors, customers, and partners, we want to be open about what's going on. We also want to allow everyone to learn from our data and choices, just as we did from so many other COSS (Commercial Open Source) startups. The term "Open Startup" isn't precisely defined (and probably will never be, just like startup). There is however a [great write-up](https://cal.com/blog/open-startup) about the basics by the founder of our favorite open source scheduling tool Cal.com. If you follow us, you know we're firmly committed to the open source values of openness and transparency. For us, this includes not only the code side of things but also the business. As we aim to build trust among our investors, customers, and partners, we want to be open about what's going on. We also want to allow everyone to learn from our data and choices, just as we did from so many other COSS (Commercial Open Source) startups. The term "Open Startup" isn't precisely defined (and probably will never be, just like startup). There is however a [great write-up](https://cal.com/blog/open-startup) about the basics by the founder of our favorite open source scheduling tool Cal.com.
The two main takeaways are: The two main takeaways are:

View File

@@ -30,7 +30,7 @@ We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania)
## Documenso Merch Shop ## Documenso Merch Shop
The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contributing to Documenso. The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contrinuting to Documenso.
<figure> <figure>
<MdxNextImage <MdxNextImage

View File

@@ -24,9 +24,9 @@ Were an open-source project and focus on building a great developer experienc
So, were switching all conversations, team and community-wide, to Discord. So, were switching all conversations, team and community-wide, to Discord.
In this post, we wont debate _why_ were switching — Slack vs. Discord is a long-lasting debate with pros and cons, and fans on both sides. There are great [stories](https://blog.meilisearch.com/from-slack-to-discord-our-migration/) and [threads](https://twitter.com/McPizza0/status/1655519558600470528) on the topic. We just dont want to write yet another story here. In this post, we wont debate *why* were switching — Slack vs. Discord is a long-lasting debate with pros and cons, and fans on both sides. There are great [stories](https://blog.meilisearch.com/from-slack-to-discord-our-migration/) and [threads](https://twitter.com/McPizza0/status/1655519558600470528) on the topic. We just dont want to write yet another story here.
Instead, well focus on _how_ we plan to make the switch. Instead, well focus on *how* we plan to make the switch.
## Who is this story for? ## Who is this story for?
@@ -47,13 +47,14 @@ The detailed plan goes like this:
- 2023-08-02 `t+8`: We announce to the community the upcoming changes in the different channels — GitHub, Twitter, and Slack. - 2023-08-02 `t+8`: We announce to the community the upcoming changes in the different channels — GitHub, Twitter, and Slack.
- **GitHub** - **GitHub**
- Create new Pull Request - Create new Pull Request
- Add story to the blog - Add story to the blog
- Update link to the community - Update link to the community
``` ```
https://documen.so/discord https://documen.so/discord
``` ```
- Start a new Discussion - Start a new Discussion
```markdown ```markdown
@@ -72,7 +73,6 @@ The detailed plan goes like this:
``` ```
- **Twitter** - **Twitter**
- [Tweet the announcement](https://twitter.com/documenso/status/1686719482096766977) - [Tweet the announcement](https://twitter.com/documenso/status/1686719482096766977)
- Pin Tweet - Pin Tweet
- Update link in bio - Update link in bio
@@ -87,7 +87,6 @@ The detailed plan goes like this:
``` ```
- **Slack** - **Slack**
- Post message in `#general` - Post message in `#general`
```markdown ```markdown
@@ -107,15 +106,15 @@ The detailed plan goes like this:
- Pin post - Pin post
- Set topic and description - Set topic and description
``` ```
We're switching to Discord. Join the fun: https://documen.so/discord We're switching to Discord. Join the fun: https://documen.so/discord
``` ```
- Archive channels: `#code-review` `#how-to` `#meet-and-greet` `#random-memes` `#self-hosting` `#support` - Archive channels: `#code-review` `#how-to` `#meet-and-greet` `#random-memes` `#self-hosting` `#support`
- 2023-08-09 `t+15`: 7 days later, we send a reminder on Slack. - 2023-08-09 `t+15`: 7 days later, we send a reminder on Slack.
- **Slack** - **Slack**
- Schedule reminder in `#general` - Schedule reminder in `#general`
``` ```
@@ -132,5 +131,5 @@ The detailed plan goes like this:
## Final thoughts ## Final thoughts
- Were at the very, early stage on our journey to building a beautiful, open-source DocuSign alternative. We want to build a great developer experience with the open-source community and, switching to Discord, we want to set up the foundations of an open, safe place for community members to get in touch, brainstorm ideas, and have fun. - Were at the very, early stage on our journey to building a beautiful, open-source DocuSign alternative. We want to build a great developer experience with the open-source community and, switching to Discord, we want to set up the foundations of an open, safe place for community members to get in touch, brainstorm ideas, and have fun.
- It doesnt mean we wont ever switch back to Slack. The tools of today arent the ones of tomorrow. We dont delete the Slack workspace, we archive it, and keep the `documenso` handle. May it be just an _au revoir?_ - It doesnt mean we wont ever switch back to Slack. The tools of today arent the ones of tomorrow. We dont delete the Slack workspace, we archive it, and keep the `documenso` handle. May it be just an *au revoir?*
- For now, were pushing forward and are eager to welcome you on Discord. Make sure to [join the server](https://documen.so/discord) in order to keep up to date on all things Documenso. See you there! - For now, were pushing forward and are eager to welcome you on Discord. Make sure to [join the server](https://documen.so/discord) in order to keep up to date on all things Documenso. See you there!

View File

@@ -1,13 +0,0 @@
---
title: Careers at Documenso
---
# Careers at Documenso
So you love Documenso and all the things that we do and now you want to work with us to unlock the future of open signing?
---
## Open Positions
Unfortunately we have no open positions available at the moment. Our team has grown and so we must grow with it, please check back from time to time as now is not forever and we may be hiring again in the future.

View File

@@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const fs = require('fs');
const path = require('path'); const path = require('path');
const { withContentlayer } = require('next-contentlayer'); const { withContentlayer } = require('next-contentlayer');
@@ -11,31 +10,15 @@ ENV_FILES.forEach((file) => {
}); });
}); });
// !: This is a temp hack to get caveat working without placing it back in the public directory.
// !: By inlining this at build time we should be able to sign faster.
const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
);
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const config = { const config = {
experimental: { experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'), serverActionsBodySizeLimit: '10mb',
serverActions: {
bodySizeLimit: '50mb',
},
}, },
reactStrictMode: true, reactStrictMode: true,
transpilePackages: [ transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
'@documenso/assets',
'@documenso/lib',
'@documenso/tailwind-config',
'@documenso/trpc',
'@documenso/ui',
],
env: { env: {
NEXT_PUBLIC_PROJECT: 'marketing', NEXT_PUBLIC_PROJECT: 'marketing',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
}, },
modularizeImports: { modularizeImports: {
'lucide-react': { 'lucide-react': {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@documenso/marketing", "name": "@documenso/marketing",
"version": "1.2.3", "version": "0.1.0",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
@@ -8,12 +8,10 @@
"build": "next build", "build": "next build",
"start": "next start -p 3001", "start": "next start -p 3001",
"lint": "next lint", "lint": "next lint",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules", "clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
}, },
"dependencies": { "dependencies": {
"@documenso/assets": "*",
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/tailwind-config": "*", "@documenso/tailwind-config": "*",
"@documenso/trpc": "*", "@documenso/trpc": "*",
@@ -24,8 +22,8 @@
"lucide-react": "^0.279.0", "lucide-react": "^0.279.0",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "14.0.3", "next": "14.0.0",
"next-auth": "4.24.5", "next-auth": "4.24.3",
"next-contentlayer": "^0.3.4", "next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1", "next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0", "perfect-freehand": "^1.2.0",
@@ -36,7 +34,7 @@
"react-hook-form": "^7.43.9", "react-hook-form": "^7.43.9",
"react-icons": "^4.11.0", "react-icons": "^4.11.0",
"recharts": "^2.7.2", "recharts": "^2.7.2",
"sharp": "0.33.1", "sharp": "0.32.5",
"typescript": "5.2.2", "typescript": "5.2.2",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
@@ -44,13 +42,5 @@
"@types/node": "20.1.0", "@types/node": "20.1.0",
"@types/react": "18.2.18", "@types/react": "18.2.18",
"@types/react-dom": "18.2.7" "@types/react-dom": "18.2.7"
},
"overrides": {
"next-auth": {
"next": "$next"
},
"next-contentlayer": {
"next": "$next"
}
} }
} }

View File

@@ -6,6 +6,8 @@ declare namespace NodeJS {
NEXT_PRIVATE_DATABASE_URL: string; NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 629 B

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -4,13 +4,13 @@ import { allBlogPosts } from 'contentlayer/generated';
export const runtime = 'edge'; export const runtime = 'edge';
export const contentType = 'image/png'; export const size = {
export const IMAGE_SIZE = {
width: 1200, width: 1200,
height: 630, height: 630,
}; };
export const contentType = 'image/png';
type BlogPostOpenGraphImageProps = { type BlogPostOpenGraphImageProps = {
params: { post: string }; params: { post: string };
}; };
@@ -25,16 +25,16 @@ export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGra
// The long urls are needed for a compiler optimisation on the Next.js side, lifting this up // The long urls are needed for a compiler optimisation on the Next.js side, lifting this up
// to a constant will break og image generation. // to a constant will break og image generation.
const [interBold, interRegular, backgroundImage, logoImage] = await Promise.all([ const [interBold, interRegular, backgroundImage, logoImage] = await Promise.all([
fetch(new URL('@documenso/assets/fonts/inter-bold.ttf', import.meta.url)).then(async (res) => fetch(new URL('./../../../../assets/inter-bold.ttf', import.meta.url)).then(async (res) =>
res.arrayBuffer(), res.arrayBuffer(),
), ),
fetch(new URL('@documenso/assets/fonts/inter-regular.ttf', import.meta.url)).then(async (res) => fetch(new URL('./../../../../assets/inter-regular.ttf', import.meta.url)).then(async (res) =>
res.arrayBuffer(), res.arrayBuffer(),
), ),
fetch(new URL('@documenso/assets/images/background-blog-og.png', import.meta.url)).then( fetch(new URL('./../../../../assets/background-blog-og.png', import.meta.url)).then(
async (res) => res.arrayBuffer(), async (res) => res.arrayBuffer(),
), ),
fetch(new URL('@documenso/assets/logo.png', import.meta.url)).then(async (res) => fetch(new URL('./../../../../../public/logo.png', import.meta.url)).then(async (res) =>
res.arrayBuffer(), res.arrayBuffer(),
), ),
]); ]);
@@ -56,7 +56,7 @@ export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGra
</div> </div>
), ),
{ {
...IMAGE_SIZE, ...size,
fonts: [ fonts: [
{ {
name: 'Inter', name: 'Inter',

View File

@@ -29,7 +29,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
return ( return (
<div <div
className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', { className={cn('relative max-w-[100vw] pt-20 md:pt-28', {
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer', 'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
})} })}
> >
@@ -41,7 +41,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" /> <Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div> </div>
<div className="relative mx-auto max-w-screen-xl flex-1 px-4 lg:px-8">{children}</div> <div className="relative mx-auto max-w-screen-xl px-4 lg:px-8">{children}</div>
<Footer className="bg-background border-muted mt-24 border-t" /> <Footer className="bg-background border-muted mt-24 border-t" />
</div> </div>

View File

@@ -3,7 +3,7 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
export type MonthlyNewUsersChartProps = { export type MonthlyNewUsersChartProps = {
@@ -22,7 +22,7 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
return ( return (
<div className={cn('flex flex-col', className)}> <div className={cn('flex flex-col', className)}>
<div className="flex items-center px-4"> <div className="flex items-center px-4">
<h3 className="text-lg font-semibold">New Users</h3> <h3 className="text-lg font-semibold">Monthly New Users</h3>
</div> </div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow"> <div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">

View File

@@ -3,7 +3,7 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
export type MonthlyTotalUsersChartProps = { export type MonthlyTotalUsersChartProps = {
@@ -22,7 +22,7 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
return ( return (
<div className={cn('flex flex-col', className)}> <div className={cn('flex flex-col', className)}>
<div className="flex items-center px-4"> <div className="flex items-center px-4">
<h3 className="text-lg font-semibold">Total Users</h3> <h3 className="text-lg font-semibold">Monthly Total Users</h3>
</div> </div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow"> <div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">

View File

@@ -18,14 +18,6 @@ export const revalidate = 3600;
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
const GITHUB_HEADERS: Record<string, string> = {
accept: 'application/vnd.github.v3+json',
};
if (process.env.NEXT_PRIVATE_GITHUB_TOKEN) {
GITHUB_HEADERS.authorization = `Bearer ${process.env.NEXT_PRIVATE_GITHUB_TOKEN}`;
}
const ZGithubStatsResponse = z.object({ const ZGithubStatsResponse = z.object({
stargazers_count: z.number(), stargazers_count: z.number(),
forks_count: z.number(), forks_count: z.number(),
@@ -36,10 +28,6 @@ const ZMergedPullRequestsResponse = z.object({
total_count: z.number(), total_count: z.number(),
}); });
const ZOpenIssuesResponse = z.object({
total_count: z.number(),
});
const ZStargazersLiveResponse = z.record( const ZStargazersLiveResponse = z.record(
z.object({ z.object({
stars: z.number(), stars: z.number(),
@@ -60,76 +48,49 @@ const ZEarlyAdoptersResponse = z.record(
export type StargazersType = z.infer<typeof ZStargazersLiveResponse>; export type StargazersType = z.infer<typeof ZStargazersLiveResponse>;
export type EarlyAdoptersType = z.infer<typeof ZEarlyAdoptersResponse>; export type EarlyAdoptersType = z.infer<typeof ZEarlyAdoptersResponse>;
const fetchGithubStats = async () => { export default async function OpenPage() {
return await fetch('https://api.github.com/repos/documenso/documenso', { const GITHUB_HEADERS: Record<string, string> = {
headers: { accept: 'application/vnd.github.v3+json',
...GITHUB_HEADERS, };
},
if (process.env.NEXT_PRIVATE_GITHUB_TOKEN) {
GITHUB_HEADERS.authorization = `Bearer ${process.env.NEXT_PRIVATE_GITHUB_TOKEN}`;
}
const {
forks_count: forksCount,
open_issues: openIssues,
stargazers_count: stargazersCount,
} = await fetch('https://api.github.com/repos/documenso/documenso', {
headers: GITHUB_HEADERS,
}) })
.then(async (res) => res.json()) .then(async (res) => res.json())
.then((res) => ZGithubStatsResponse.parse(res)); .then((res) => ZGithubStatsResponse.parse(res));
};
const fetchOpenIssues = async () => { const { total_count: mergedPullRequests } = await fetch(
return await fetch(
'https://api.github.com/search/issues?q=repo:documenso/documenso+type:issue+state:open&page=0&per_page=1',
{
headers: {
...GITHUB_HEADERS,
},
},
)
.then(async (res) => res.json())
.then((res) => ZOpenIssuesResponse.parse(res));
};
const fetchMergedPullRequests = async () => {
return await fetch(
'https://api.github.com/search/issues?q=repo:documenso/documenso/+is:pr+merged:>=2010-01-01&page=0&per_page=1', 'https://api.github.com/search/issues?q=repo:documenso/documenso/+is:pr+merged:>=2010-01-01&page=0&per_page=1',
{ {
headers: { headers: GITHUB_HEADERS,
...GITHUB_HEADERS,
},
}, },
) )
.then(async (res) => res.json()) .then(async (res) => res.json())
.then((res) => ZMergedPullRequestsResponse.parse(res)); .then((res) => ZMergedPullRequestsResponse.parse(res));
};
const fetchStargazers = async () => { const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', {
return await fetch('https://stargrazer-live.onrender.com/api/stats', {
headers: { headers: {
accept: 'application/json', accept: 'application/json',
}, },
}) })
.then(async (res) => res.json()) .then(async (res) => res.json())
.then((res) => ZStargazersLiveResponse.parse(res)); .then((res) => ZStargazersLiveResponse.parse(res));
};
const fetchEarlyAdopters = async () => { const EARLY_ADOPTERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
return await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
headers: { headers: {
accept: 'application/json', accept: 'application/json',
}, },
}) })
.then(async (res) => res.json()) .then(async (res) => res.json())
.then((res) => ZEarlyAdoptersResponse.parse(res)); .then((res) => ZEarlyAdoptersResponse.parse(res));
};
export default async function OpenPage() {
const [
{ forks_count: forksCount, stargazers_count: stargazersCount },
{ total_count: openIssues },
{ total_count: mergedPullRequests },
STARGAZERS_DATA,
EARLY_ADOPTERS_DATA,
] = await Promise.all([
fetchGithubStats(),
fetchOpenIssues(),
fetchMergedPullRequests(),
fetchStargazers(),
fetchEarlyAdopters(),
]);
const MONTHLY_USERS = await getUserMonthlyGrowth(); const MONTHLY_USERS = await getUserMonthlyGrowth();

View File

@@ -29,7 +29,10 @@ export function OpenPageTooltip() {
</svg> </svg>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Active Subscriptions.</p> <p>
August and earlier: Active subscribers. September and beyond: Numbers of active
subscriptions.
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>

View File

@@ -2,7 +2,7 @@ import Image from 'next/image';
import { z } from 'zod'; import { z } from 'zod';
import backgroundPattern from '@documenso/assets/images/background-pattern.png'; import backgroundPattern from '~/assets/background-pattern.png';
import { OSSFriendsContainer } from './container'; import { OSSFriendsContainer } from './container';
import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema'; import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema';

View File

@@ -1,5 +1,3 @@
'use client';
import Link from 'next/link'; import Link from 'next/link';
import { import {

View File

@@ -8,23 +8,24 @@ import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { base64 } from '@documenso/lib/universal/base64'; import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client'; import { DocumentDataType, Field, Prisma, Recipient } from '@documenso/prisma/client';
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields'; import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types'; import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature'; import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types'; import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import {
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const SinglePlayerModeSteps = ['fields', 'sign'] as const; import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
type SinglePlayerModeStep = (typeof SinglePlayerModeSteps)[number];
type SinglePlayerModeStep = 'fields' | 'sign';
// !: This entire file is a hack to get around failed prerendering of // !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during // !: the Single Player Mode page. This regression was introduced during
@@ -40,9 +41,6 @@ export const SinglePlayerClient = () => {
const [step, setStep] = useState<SinglePlayerModeStep>('fields'); const [step, setStep] = useState<SinglePlayerModeStep>('fields');
const [fields, setFields] = useState<Field[]>([]); const [fields, setFields] = useState<Field[]>([]);
const { mutateAsync: createSinglePlayerDocument } =
trpc.singleplayer.createSinglePlayerDocument.useMutation();
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = { const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
fields: { fields: {
title: 'Add document', title: 'Add document',
@@ -86,7 +84,6 @@ export const SinglePlayerClient = () => {
data.fields.map((field, i) => ({ data.fields.map((field, i) => ({
id: i, id: i,
documentId: -1, documentId: -1,
templateId: null,
recipientId: -1, recipientId: -1,
type: field.type, type: field.type,
page: field.pageNumber, page: field.pageNumber,
@@ -149,7 +146,6 @@ export const SinglePlayerClient = () => {
const placeholderRecipient: Recipient = { const placeholderRecipient: Recipient = {
id: -1, id: -1,
documentId: -1, documentId: -1,
templateId: null,
email: '', email: '',
name: '', name: '',
token: '', token: '',
@@ -227,35 +223,37 @@ export const SinglePlayerClient = () => {
</div> </div>
<div className="col-span-12 lg:col-span-6 xl:col-span-5"> <div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer <DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
className="top-24 lg:h-[calc(100vh-7rem)]" <DocumentFlowFormContainerHeader
onSubmit={(e) => e.preventDefault()} title={currentDocumentFlow.title}
> description={currentDocumentFlow.description}
<Stepper />
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(SinglePlayerModeSteps[step - 1])}
>
{/* Add fields to PDF page. */} {/* Add fields to PDF page. */}
{step === 'fields' && (
<fieldset disabled={!uploadedFile} className="flex h-full flex-col"> <fieldset disabled={!uploadedFile} className="flex h-full flex-col">
<AddFieldsFormPartial <AddFieldsFormPartial
documentFlow={documentFlow.fields} documentFlow={documentFlow.fields}
hideRecipients={true} hideRecipients={true}
recipients={uploadedFile ? [placeholderRecipient] : []} recipients={uploadedFile ? [placeholderRecipient] : []}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields} fields={fields}
onSubmit={onFieldsSubmit} onSubmit={onFieldsSubmit}
/> />
</fieldset> </fieldset>
)}
{/* Enter user details and signature. */} {/* Enter user details and signature. */}
{step === 'sign' && (
<AddSignatureFormPartial <AddSignatureFormPartial
documentFlow={documentFlow.sign} documentFlow={documentFlow.sign}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields} fields={fields}
onSubmit={onSignSubmit} onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))} requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))} requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/> />
</Stepper> )}
</DocumentFlowFormContainer> </DocumentFlowFormContainer>
</div> </div>
</div> </div>

View File

@@ -7,10 +7,11 @@ import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import backgroundPattern from '~/assets/background-pattern.png';
export default function NotFound() { export default function NotFound() {
const router = useRouter(); const router = useRouter();

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 896 KiB

After

Width:  |  Height:  |  Size: 896 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 337 KiB

View File

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 251 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 394 KiB

After

Width:  |  Height:  |  Size: 394 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 254 KiB

View File

Before

Width:  |  Height:  |  Size: 20 MiB

After

Width:  |  Height:  |  Size: 20 MiB

View File

@@ -0,0 +1,160 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Info } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { claimPlan } from '~/api/claim-plan/fetcher';
import { FormErrorMessage } from '../form/form-error-message';
export const ZClaimPlanDialogFormSchema = z.object({
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
email: z.string().email(),
});
export type TClaimPlanDialogFormSchema = z.infer<typeof ZClaimPlanDialogFormSchema>;
export type ClaimPlanDialogProps = {
className?: string;
planId: string;
children: React.ReactNode;
};
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
const params = useSearchParams();
const analytics = useAnalytics();
const event = usePlausible();
const { toast } = useToast();
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<TClaimPlanDialogFormSchema>({
defaultValues: {
name: params?.get('name') ?? '',
email: params?.get('email') ?? '',
},
resolver: zodResolver(ZClaimPlanDialogFormSchema),
});
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
try {
const delay = new Promise<void>((resolve) => {
setTimeout(resolve, 1000);
});
const [redirectUrl] = await Promise.all([
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
delay,
]);
event('claim-plan-pricing');
analytics.capture('Marketing: Claim plan', { planId, email });
window.location.href = redirectUrl;
} catch (error) {
event('claim-plan-failed');
analytics.capture('Marketing: Claim plan failure', { planId, email });
toast({
title: 'Something went wrong',
description: error instanceof Error ? error.message : 'Please try again later.',
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isSubmitting && !open) {
reset();
}
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Claim your plan</DialogTitle>
<DialogDescription className="mt-4">
We're almost there! Please enter your email address and name to claim your plan.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting} className={cn('flex flex-col gap-y-4', className)}>
{params?.get('cancelled') === 'true' && (
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<Info className="h-5 w-5 text-yellow-400" />
</div>
<div className="ml-3">
<p className="text-sm leading-5 text-yellow-700">
You have cancelled the payment process. If you didn't mean to do this, please
try again.
</p>
</div>
</div>
</div>
)}
<div>
<Label className="text-muted-foreground">Name</Label>
<Input type="text" className="mt-2" {...register('name')} autoFocus />
<FormErrorMessage className="mt-1" error={errors.name} />
</div>
<div>
<Label className="text-muted-foreground">Email</Label>
<Input type="email" className="mt-2" {...register('email')} />
<FormErrorMessage className="mt-1" error={errors.email} />
</div>
<Button type="submit" size="lg" loading={isSubmitting}>
Claim the early adopters Plan (
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
? 'Monthly'
: 'Yearly'}
)
</Button>
</fieldset>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -2,13 +2,14 @@ import { HTMLAttributes } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import cardBeautifulFigure from '@documenso/assets/images/card-beautiful-figure.png';
import cardFastFigure from '@documenso/assets/images/card-fast-figure.png';
import cardSmartFigure from '@documenso/assets/images/card-smart-figure.png';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import backgroundPattern from '~/assets/background-pattern.png';
import cardBeautifulFigure from '~/assets/card-beautiful-figure.png';
import cardFastFigure from '~/assets/card-fast-figure.png';
import cardSmartFigure from '~/assets/card-smart-figure.png';
export type FasterSmarterBeautifulBentoProps = HTMLAttributes<HTMLDivElement>; export type FasterSmarterBeautifulBentoProps = HTMLAttributes<HTMLDivElement>;
export const FasterSmarterBeautifulBento = ({ export const FasterSmarterBeautifulBento = ({

View File

@@ -1,17 +1,17 @@
'use client'; 'use client';
import type { HTMLAttributes } from 'react'; import { HTMLAttributes } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { FaXTwitter } from 'react-icons/fa6'; import { FaXTwitter } from 'react-icons/fa6';
import { LiaDiscord } from 'react-icons/lia'; import { LiaDiscord } from 'react-icons/lia';
import { LuGithub } from 'react-icons/lu'; import { LuGithub } from 'react-icons/lu';
import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
export type FooterProps = HTMLAttributes<HTMLDivElement>; export type FooterProps = HTMLAttributes<HTMLDivElement>;
@@ -26,23 +26,23 @@ const FOOTER_LINKS = [
{ href: '/singleplayer', text: 'Singleplayer' }, { href: '/singleplayer', text: 'Singleplayer' },
{ href: '/blog', text: 'Blog' }, { href: '/blog', text: 'Blog' },
{ href: '/design-system', text: 'Design' }, { href: '/design-system', text: 'Design' },
{ href: '/open', text: 'Open Startup' }, { 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', target: '_blank' }, { href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
{ href: '/oss-friends', text: 'OSS Friends' },
{ href: '/careers', text: 'Careers' },
{ href: '/privacy', text: 'Privacy' }, { href: '/privacy', text: 'Privacy' },
]; ];
export const Footer = ({ className, ...props }: FooterProps) => { export const Footer = ({ className, ...props }: FooterProps) => {
const { setTheme } = useTheme();
return ( return (
<div className={cn('border-t py-12', className)} {...props}> <div className={cn('border-t py-12', className)} {...props}>
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8"> <div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
<div className="flex-shrink-0"> <div>
<Link href="/"> <Link href="/">
<Image <Image
src={LogoImage} src="/logo.png"
alt="Documenso Logo" alt="Documenso Logo"
className="dark:invert" className="dark:invert"
width={170} width={170}
@@ -64,26 +64,34 @@ export const Footer = ({ className, ...props }: FooterProps) => {
</div> </div>
</div> </div>
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8"> <div className="grid max-w-xs flex-1 grid-cols-2 gap-x-4 gap-y-2">
{FOOTER_LINKS.map((link, index) => ( {FOOTER_LINKS.map((link, index) => (
<Link <Link
key={index} key={index}
href={link.href} href={link.href}
target={link.target} target={link.target}
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 break-words text-sm" className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 text-sm"
> >
{link.text} {link.text}
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap items-center justify-between gap-4 px-8 md:mt-12 lg:mt-24"> <div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap justify-between gap-4 px-8 md:mt-12 lg:mt-24">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
© {new Date().getFullYear()} Documenso, Inc. All rights reserved. © {new Date().getFullYear()} Documenso, Inc. All rights reserved.
</p> </p>
<div className="flex flex-wrap"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
<ThemeSwitcher /> <button type="button" className="text-muted-foreground" onClick={() => setTheme('light')}>
<Sun className="h-5 w-5" />
<span className="sr-only">Light</span>
</button>
<button type="button" className="text-muted-foreground" onClick={() => setTheme('dark')}>
<Moon className="h-5 w-5" />
<span className="sr-only">Dark</span>
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,12 +1,10 @@
'use client'; 'use client';
import type { HTMLAttributes } from 'react'; import { HTMLAttributes, useState } from 'react';
import { useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import LogoImage from '@documenso/assets/logo.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -27,7 +25,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}> <Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
<Image <Image
src={LogoImage} src="/logo.png"
alt="Documenso Logo" alt="Documenso Logo"
className="dark:invert" className="dark:invert"
width={170} width={170}
@@ -64,7 +62,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
href="/open" href="/open"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold" className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
> >
Open Startup Open
</Link> </Link>
<Link <Link

View File

@@ -8,11 +8,12 @@ import { usePlausible } from 'next-plausible';
import { LuGithub } from 'react-icons/lu'; import { LuGithub } from 'react-icons/lu';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import backgroundPattern from '~/assets/background-pattern.png';
import { Widget } from './widget'; import { Widget } from './widget';
export type HeroProps = { export type HeroProps = {

View File

@@ -8,7 +8,6 @@ import { FaXTwitter } from 'react-icons/fa6';
import { LiaDiscord } from 'react-icons/lia'; import { LiaDiscord } from 'react-icons/lia';
import { LuGithub } from 'react-icons/lu'; import { LuGithub } from 'react-icons/lu';
import LogoImage from '@documenso/assets/logo.png';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet'; import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
export type MobileNavigationProps = { export type MobileNavigationProps = {
@@ -31,7 +30,7 @@ export const MENU_NAVIGATION_LINKS = [
}, },
{ {
href: '/open', href: '/open',
text: 'Open Startup', text: 'Open',
}, },
{ {
href: 'https://status.documenso.com', href: 'https://status.documenso.com',
@@ -64,7 +63,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
<SheetContent className="w-full max-w-[400px]"> <SheetContent className="w-full max-w-[400px]">
<Link href="/" className="z-10" onClick={handleMenuItemClick}> <Link href="/" className="z-10" onClick={handleMenuItemClick}>
<Image <Image
src={LogoImage} src="/logo.png"
alt="Documenso Logo" alt="Documenso Logo"
className="dark:invert" className="dark:invert"
width={170} width={170}

View File

@@ -2,13 +2,14 @@ import { HTMLAttributes } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import cardBuildFigure from '@documenso/assets/images/card-build-figure.png';
import cardOpenFigure from '@documenso/assets/images/card-open-figure.png';
import cardTemplateFigure from '@documenso/assets/images/card-template-figure.png';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import backgroundPattern from '~/assets/background-pattern.png';
import cardBuildFigure from '~/assets/card-build-figure.png';
import cardOpenFigure from '~/assets/card-open-figure.png';
import cardTemplateFigure from '~/assets/card-template-figure.png';
export type OpenBuildTemplateBentoProps = HTMLAttributes<HTMLDivElement>; export type OpenBuildTemplateBentoProps = HTMLAttributes<HTMLDivElement>;
export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplateBentoProps) => { export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplateBentoProps) => {

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import type { HTMLAttributes } from 'react'; import { HTMLAttributes, useState } from 'react';
import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { usePlausible } from 'next-plausible'; import { usePlausible } from 'next-plausible';
@@ -16,9 +16,14 @@ export type PricingTableProps = HTMLAttributes<HTMLDivElement>;
const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar'; const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar';
export const PricingTable = ({ className, ...props }: PricingTableProps) => { export const PricingTable = ({ className, ...props }: PricingTableProps) => {
const params = useSearchParams();
const event = usePlausible(); const event = usePlausible();
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>('MONTHLY'); const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() =>
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
? 'YEARLY'
: 'MONTHLY',
);
return ( return (
<div className={cn('', className)} {...props}> <div className={cn('', className)} {...props}>

View File

@@ -2,14 +2,15 @@ import { HTMLAttributes } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import cardConnectionsFigure from '@documenso/assets/images/card-connections-figure.png';
import cardPaidFigure from '@documenso/assets/images/card-paid-figure.png';
import cardSharingFigure from '@documenso/assets/images/card-sharing-figure.png';
import cardWidgetFigure from '@documenso/assets/images/card-widget-figure.png';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import backgroundPattern from '~/assets/background-pattern.png';
import cardConnectionsFigure from '~/assets/card-connections-figure.png';
import cardPaidFigure from '~/assets/card-paid-figure.png';
import cardSharingFigure from '~/assets/card-sharing-figure.png';
import cardWidgetFigure from '~/assets/card-widget-figure.png';
export type ShareConnectPaidWidgetBentoProps = HTMLAttributes<HTMLDivElement>; export type ShareConnectPaidWidgetBentoProps = HTMLAttributes<HTMLDivElement>;
export const ShareConnectPaidWidgetBento = ({ export const ShareConnectPaidWidgetBento = ({

View File

@@ -0,0 +1,233 @@
'use server';
import { createElement } from 'react';
import { DateTime } from 'luxon';
import { PDFDocument } from 'pdf-lib';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { alphaid } from '@documenso/lib/universal/id';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma';
import {
DocumentDataType,
DocumentStatus,
FieldType,
Prisma,
ReadStatus,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
const ZCreateSinglePlayerDocumentSchema = z.object({
documentData: z.object({
data: z.string(),
type: z.nativeEnum(DocumentDataType),
}),
documentName: z.string(),
signer: z.object({
email: z.string().email().min(1),
name: z.string(),
signature: z.string(),
}),
fields: z.array(
z.object({
page: z.number(),
type: z.nativeEnum(FieldType),
positionX: z.number(),
positionY: z.number(),
width: z.number(),
height: z.number(),
}),
),
});
export type TCreateSinglePlayerDocumentSchema = z.infer<typeof ZCreateSinglePlayerDocumentSchema>;
/**
* Create and self signs a document.
*
* Returns the document token.
*/
export const createSinglePlayerDocument = async (
value: TCreateSinglePlayerDocumentSchema,
): Promise<string> => {
const { signer, fields, documentData, documentName } =
ZCreateSinglePlayerDocumentSchema.parse(value);
const document = await getFile({
data: documentData.data,
type: documentData.type,
});
const doc = await PDFDocument.load(document);
const createdAt = new Date();
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
const typedSignature = !isBase64 ? signer.signature : null;
// Update the document with the fields inserted.
for (const field of fields) {
const isSignatureField = field.type === FieldType.SIGNATURE;
await insertFieldInPDF(doc, {
...mapField(field, signer),
Signature: isSignatureField
? {
created: createdAt,
signatureImageAsBase64,
typedSignature,
// Dummy data.
id: -1,
recipientId: -1,
fieldId: -1,
}
: null,
// Dummy data.
id: -1,
documentId: -1,
recipientId: -1,
});
}
const unsignedPdfBytes = await doc.save();
const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) });
const { token } = await prisma.$transaction(
async (tx) => {
const token = alphaid();
// Fetch service user who will be the owner of the document.
const serviceUser = await tx.user.findFirstOrThrow({
where: {
email: SERVICE_USER_EMAIL,
},
});
const { id: documentDataId } = await putFile({
name: `${documentName}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
});
// Create document.
const document = await tx.document.create({
data: {
title: documentName,
status: DocumentStatus.COMPLETED,
documentDataId,
userId: serviceUser.id,
createdAt,
},
});
// Create recipient.
const recipient = await tx.recipient.create({
data: {
documentId: document.id,
name: signer.name,
email: signer.email,
token,
signedAt: createdAt,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
},
});
// Create fields and signatures.
await Promise.all(
fields.map(async (field) => {
const insertedField = await tx.field.create({
data: {
documentId: document.id,
recipientId: recipient.id,
...mapField(field, signer),
},
});
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
await tx.signature.create({
data: {
fieldId: insertedField.id,
signatureImageAsBase64,
typedSignature,
recipientId: recipient.id,
},
});
}
}),
);
return { document, token };
},
{
maxWait: 5000,
timeout: 30000,
},
);
const template = createElement(DocumentSelfSignedEmailTemplate, {
documentName: documentName,
assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
});
// Send email to signer.
await mailer.sendMail({
to: {
address: signer.email,
name: signer.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document signed',
html: render(template),
text: render(template, { plainText: true }),
attachments: [{ content: signedPdfBuffer, filename: documentName }],
});
return token;
};
/**
* Map the fields provided by the user to fields compatible with Prisma.
*
* Signature fields are handled separately.
*
* @param field The field passed in by the user.
* @param signer The details of the person who is signing this document.
* @returns A field compatible with Prisma.
*/
const mapField = (
field: TCreateSinglePlayerDocumentSchema['fields'][number],
signer: TCreateSinglePlayerDocumentSchema['signer'],
) => {
const customText = match(field.type)
.with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a'))
.with(FieldType.EMAIL, () => signer.email)
.with(FieldType.NAME, () => signer.name)
.otherwise(() => '');
return {
type: field.type,
page: field.page,
positionX: new Prisma.Decimal(field.positionX),
positionY: new Prisma.Decimal(field.positionY),
width: new Prisma.Decimal(field.width),
height: new Prisma.Decimal(field.height),
customText,
inserted: true,
};
};

View File

@@ -4,11 +4,9 @@ import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import type { Signature } from '@documenso/prisma/client'; import { DocumentStatus, Signature } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import DocumentDialog from '@documenso/ui/components/document/document-dialog'; import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@@ -16,6 +14,7 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import signingCelebration from '~/assets/signing-celebration.png';
import { ConfettiScreen } from '~/components/(marketing)/confetti-screen'; import { ConfettiScreen } from '~/components/(marketing)/confetti-screen';
interface SinglePlayerModeSuccessProps { interface SinglePlayerModeSuccessProps {

View File

@@ -1,7 +1,6 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { HTMLAttributes, KeyboardEvent, useMemo, useState } from 'react';
import type { HTMLAttributes, KeyboardEvent } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
@@ -27,7 +26,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { claimPlan } from '~/api/claim-plan/fetcher'; import { claimPlan } from '~/api/claim-plan/fetcher';
import { STEP } from '../constants';
import { FormErrorMessage } from '../form/form-error-message'; import { FormErrorMessage } from '../form/form-error-message';
const ZWidgetFormSchema = z const ZWidgetFormSchema = z
@@ -50,16 +48,13 @@ const ZWidgetFormSchema = z
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>; export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
type StepKeys = keyof typeof STEP;
type StepValues = (typeof STEP)[StepKeys];
export type WidgetProps = HTMLAttributes<HTMLDivElement>; export type WidgetProps = HTMLAttributes<HTMLDivElement>;
export const Widget = ({ className, children, ...props }: WidgetProps) => { export const Widget = ({ className, children, ...props }: WidgetProps) => {
const { toast } = useToast(); const { toast } = useToast();
const event = usePlausible(); const event = usePlausible();
const [step, setStep] = useState<StepValues>(STEP.EMAIL); const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL');
const [showSigningDialog, setShowSigningDialog] = useState(false); const [showSigningDialog, setShowSigningDialog] = useState(false);
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null); const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
@@ -86,28 +81,28 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
const signatureText = watch('signatureText'); const signatureText = watch('signatureText');
const stepsRemaining = useMemo(() => { const stepsRemaining = useMemo(() => {
if (step === STEP.NAME) { if (step === 'NAME') {
return 2; return 2;
} }
if (step === STEP.EMAIL) { if (step === 'SIGN') {
return 3; return 1;
} }
return 1; return 3;
}, [step]); }, [step]);
const onNextStepClick = () => { const onNextStepClick = () => {
if (step === STEP.EMAIL) { if (step === 'EMAIL') {
setStep(STEP.NAME); setStep('NAME');
setTimeout(() => { setTimeout(() => {
document.querySelector<HTMLElement>('#name')?.focus(); document.querySelector<HTMLElement>('#name')?.focus();
}, 0); }, 0);
} }
if (step === STEP.NAME) { if (step === 'NAME') {
setStep(STEP.SIGN); setStep('SIGN');
setTimeout(() => { setTimeout(() => {
document.querySelector<HTMLElement>('#signatureText')?.focus(); document.querySelector<HTMLElement>('#signatureText')?.focus();
@@ -231,7 +226,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
type="button" type="button"
className="bg-primary h-full w-14 rounded" className="bg-primary h-full w-14 rounded"
disabled={!field.value || !!errors.email?.message} disabled={!field.value || !!errors.email?.message}
onClick={() => step === STEP.EMAIL && onNextStepClick()} onClick={() => step === 'EMAIL' && onNextStepClick()}
> >
Next Next
</Button> </Button>
@@ -243,7 +238,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<FormErrorMessage error={errors.email} className="mt-1" /> <FormErrorMessage error={errors.email} className="mt-1" />
</motion.div> </motion.div>
{(step === STEP.NAME || step === STEP.SIGN) && ( {(step === 'NAME' || step === 'SIGN') && (
<motion.div <motion.div
key="name" key="name"
className="mt-4" className="mt-4"
@@ -355,7 +350,6 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<div <div
className="absolute inset-x-0 bottom-0 flex cursor-auto items-center justify-between px-4 pb-2" className="absolute inset-x-0 bottom-0 flex cursor-auto items-center justify-between px-4 pb-2"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
> >
<Input <Input
id="signatureText" id="signatureText"
@@ -393,11 +387,10 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
</DialogHeader> </DialogHeader>
<DialogDescription> <DialogDescription>
By signing you signal your support of Documenso's mission in a <br /> By signing you signal your support of Documenso's mission in a <br></br>
<strong>non-legally binding, but heartfelt way</strong>. <br /> <strong>non-legally binding, but heartfelt way</strong>. <br></br>
<br /> <br></br>You also unlock the option to purchase the early supporter plan including
You also unlock the option to purchase the early supporter plan including everything we everything we build this year for fixed price.
build this year for fixed price.
</DialogDescription> </DialogDescription>
<SignaturePad <SignaturePad

View File

@@ -1,5 +0,0 @@
export const STEP = {
EMAIL: 'EMAIL',
NAME: 'NAME',
SIGN: 'SIGN',
} as const;

View File

@@ -1,13 +1,13 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import { NextApiRequest, NextApiResponse } from 'next';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { readFileSync } from 'fs';
import { buffer } from 'micro'; import { buffer } from 'micro';
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf'; import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf'; import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
import { redis } from '@documenso/lib/server-only/redis'; import { redis } from '@documenso/lib/server-only/redis';
import type { Stripe } from '@documenso/lib/server-only/stripe'; import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { updateFile } from '@documenso/lib/universal/upload/update-file'; import { updateFile } from '@documenso/lib/universal/upload/update-file';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@@ -88,11 +88,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const now = new Date(); const now = new Date();
const bytes64 = await fetch( const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url),
)
.then(async (res) => res.arrayBuffer())
.then((buffer) => Buffer.from(buffer).toString('base64'));
const { id: documentDataId } = await prisma.documentData.create({ const { id: documentDataId } = await prisma.documentData.create({
data: { data: {

View File

@@ -2,15 +2,6 @@ import * as trpcNext from '@documenso/trpc/server/adapters/next';
import { createTrpcContext } from '@documenso/trpc/server/context'; import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router'; import { appRouter } from '@documenso/trpc/server/router';
export const config = {
maxDuration: 60,
api: {
bodyParser: {
sizeLimit: '50mb',
},
},
};
export default trpcNext.createNextApiHandler({ export default trpcNext.createNextApiHandler({
router: appRouter, router: appRouter,
createContext: async ({ req, res }) => createTrpcContext({ req, res }), createContext: async ({ req, res }) => createTrpcContext({ req, res }),

View File

@@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const fs = require('fs');
const path = require('path'); const path = require('path');
const { version } = require('./package.json'); const { version } = require('./package.json');
@@ -11,40 +10,22 @@ ENV_FILES.forEach((file) => {
}); });
}); });
// !: This is a temp hack to get caveat working without placing it back in the public directory.
// !: By inlining this at build time we should be able to sign faster.
const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
);
const FONT_DANCING_SCRIPT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/dancing-script.ttf'),
);
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const config = { const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
experimental: { experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'), serverActionsBodySizeLimit: '50mb',
serverActions: {
bodySizeLimit: '50mb',
},
}, },
reactStrictMode: true, reactStrictMode: true,
transpilePackages: [ transpilePackages: [
'@documenso/assets',
'@documenso/ee',
'@documenso/lib', '@documenso/lib',
'@documenso/prisma', '@documenso/prisma',
'@documenso/tailwind-config',
'@documenso/trpc', '@documenso/trpc',
'@documenso/ui', '@documenso/ui',
'@documenso/email',
], ],
env: { env: {
APP_VERSION: version, APP_VERSION: version,
NEXT_PUBLIC_PROJECT: 'web', NEXT_PUBLIC_PROJECT: 'web',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
FONT_DANCING_SCRIPT_URI: `data:font/ttf;base64,${FONT_DANCING_SCRIPT_BYTES.toString('base64')}`,
}, },
modularizeImports: { modularizeImports: {
'lucide-react': { 'lucide-react': {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@documenso/web", "name": "@documenso/web",
"version": "1.2.3", "version": "0.1.0",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
@@ -8,13 +8,10 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"e2e:prepare": "next build && next start",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules", "clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
}, },
"dependencies": { "dependencies": {
"@documenso/assets": "*",
"@documenso/ee": "*", "@documenso/ee": "*",
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
@@ -28,8 +25,8 @@
"lucide-react": "^0.279.0", "lucide-react": "^0.279.0",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "14.0.3", "next": "14.0.0",
"next-auth": "4.24.5", "next-auth": "4.24.3",
"next-plausible": "^3.10.1", "next-plausible": "^3.10.1",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"perfect-freehand": "^1.2.0", "perfect-freehand": "^1.2.0",
@@ -42,10 +39,9 @@
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"react-icons": "^4.11.0", "react-icons": "^4.11.0",
"react-rnd": "^10.4.1", "react-rnd": "^10.4.1",
"sharp": "0.33.1", "sharp": "0.32.5",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"typescript": "5.2.2", "typescript": "5.2.2",
"uqr": "^0.1.2",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
@@ -54,13 +50,5 @@
"@types/node": "20.1.0", "@types/node": "20.1.0",
"@types/react": "18.2.18", "@types/react": "18.2.18",
"@types/react-dom": "18.2.7" "@types/react-dom": "18.2.7"
},
"overrides": {
"next-auth": {
"next": "$next"
},
"next-contentlayer": {
"next": "$next"
}
} }
} }

View File

@@ -6,6 +6,8 @@ declare namespace NodeJS {
NEXT_PRIVATE_DATABASE_URL: string; NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
apps/web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 743 KiB

After

Width:  |  Height:  |  Size: 743 KiB

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { AdminNav } from './nav'; import { AdminNav } from './nav';

View File

@@ -4,11 +4,12 @@ import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import type { z } from 'zod'; import { z } from 'zod';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema'; import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox';
import { import {
Form, Form,
FormControl, FormControl,
@@ -18,7 +19,6 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true }); const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
@@ -117,7 +117,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<FormLabel className="text-muted-foreground">Roles</FormLabel> <FormLabel className="text-muted-foreground">Roles</FormLabel>
<FormControl> <FormControl>
<MultiSelectCombobox <Combobox
listValues={roles} listValues={roles}
onChange={(values: string[]) => onChange(values)} onChange={(values: string[]) => onChange(values)}
/> />

View File

@@ -8,7 +8,7 @@ import { Edit, Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Document, Role, Subscription } from '@documenso/prisma/client'; import { Document, Role, Subscription } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -19,7 +19,7 @@ type UserData = {
name: string | null; name: string | null;
email: string; email: string;
roles: Role[]; roles: Role[];
Subscription?: SubscriptionLite[] | null; Subscription?: SubscriptionLite | null;
Document: DocumentLite[]; Document: DocumentLite[];
}; };
@@ -35,16 +35,9 @@ type UsersDataTableProps = {
totalPages: number; totalPages: number;
perPage: number; perPage: number;
page: number; page: number;
individualPriceIds: string[];
}; };
export const UsersDataTable = ({ export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => {
users,
totalPages,
perPage,
page,
individualPriceIds,
}: UsersDataTableProps) => {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams(); const updateSearchParams = useUpdateSearchParams();
const [searchString, setSearchString] = useState(''); const [searchString, setSearchString] = useState('');
@@ -107,13 +100,7 @@ export const UsersDataTable = ({
{ {
header: 'Subscription', header: 'Subscription',
accessorKey: 'subscription', accessorKey: 'subscription',
cell: ({ row }) => { cell: ({ row }) => row.original.Subscription?.status ?? 'NONE',
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
individualPriceIds.includes(sub.priceId),
);
return foundIndividualSubscription?.status ?? 'NONE';
},
}, },
{ {
header: 'Documents', header: 'Documents',

View File

@@ -1,16 +1,8 @@
'use server'; 'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { findUsers } from '@documenso/lib/server-only/user/get-all-users'; import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
export async function search(search: string, page: number, perPage: number) { export async function search(search: string, page: number, perPage: number) {
const { user } = await getRequiredServerComponentSession();
if (!isAdmin(user)) {
throw new Error('Unauthorized');
}
const results = await findUsers({ username: search, email: search, page, perPage }); const results = await findUsers({ username: search, email: search, page, perPage });
return results; return results;

View File

@@ -1,5 +1,3 @@
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
import { UsersDataTable } from './data-table-users'; import { UsersDataTable } from './data-table-users';
import { search } from './fetch-users.actions'; import { search } from './fetch-users.actions';
@@ -16,23 +14,12 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const perPage = Number(searchParams.perPage) || 10; const perPage = Number(searchParams.perPage) || 10;
const searchString = searchParams.search || ''; const searchString = searchParams.search || '';
const [{ users, totalPages }, individualPrices] = await Promise.all([ const { users, totalPages } = await search(searchString, page, perPage);
search(searchString, page, perPage),
getPricesByType('individual'),
]);
const individualPriceIds = individualPrices.map((price) => price.id);
return ( return (
<div> <div>
<h2 className="text-4xl font-semibold">Manage users</h2> <h2 className="text-4xl font-semibold">Manage users</h2>
<UsersDataTable <UsersDataTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
users={users}
individualPriceIds={individualPriceIds}
totalPages={totalPages}
page={page}
perPage={perPage}
/>
</div> </div>
); );
} }

View File

@@ -4,26 +4,28 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client'; import { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields'; import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types'; import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers'; import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types'; import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject'; import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types'; import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title'; import {
import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types'; DocumentFlowFormContainer,
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; DocumentFlowFormContainerHeader,
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; } from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { addFields } from '~/components/forms/edit-document/add-fields.action';
import { addSigners } from '~/components/forms/edit-document/add-signers.action';
import { completeDocument } from '~/components/forms/edit-document/add-subject.action';
export type EditDocumentFormProps = { export type EditDocumentFormProps = {
className?: string; className?: string;
user: User; user: User;
@@ -33,8 +35,7 @@ export type EditDocumentFormProps = {
documentData: DocumentData; documentData: DocumentData;
}; };
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject'; type EditDocumentStep = 'signers' | 'fields' | 'subject';
const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'subject'];
export const EditDocumentForm = ({ export const EditDocumentForm = ({
className, className,
@@ -47,60 +48,29 @@ export const EditDocumentForm = ({
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
// controlled stepper state const [step, setStep] = useState<EditDocumentStep>('signers');
const [step, setStep] = useState<EditDocumentStep>(
document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
);
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = { const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
title: {
title: 'Add Title',
description: 'Add the title to the document.',
stepIndex: 1,
},
signers: { signers: {
title: 'Add Signers', title: 'Add Signers',
description: 'Add the people who will sign the document.', description: 'Add the people who will sign the document.',
stepIndex: 2, stepIndex: 1,
}, },
fields: { fields: {
title: 'Add Fields', title: 'Add Fields',
description: 'Add all relevant fields for each recipient.', description: 'Add all relevant fields for each recipient.',
stepIndex: 3, stepIndex: 2,
onBackStep: () => setStep('signers'),
}, },
subject: { subject: {
title: 'Add Subject', title: 'Add Subject',
description: 'Add the subject and message you wish to send to signers.', description: 'Add the subject and message you wish to send to signers.',
stepIndex: 4, stepIndex: 3,
onBackStep: () => setStep('fields'),
}, },
}; };
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => { const currentDocumentFlow = documentFlow[step];
try {
// Custom invocation server action
await addTitle({
documentId: document.id,
title: data.title,
});
router.refresh();
setStep('signers');
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while updating title.',
variant: 'destructive',
});
}
};
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => { const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try { try {
@@ -111,6 +81,7 @@ export const EditDocumentForm = ({
}); });
router.refresh(); router.refresh();
setStep('fields'); setStep('fields');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -132,6 +103,7 @@ export const EditDocumentForm = ({
}); });
router.refresh(); router.refresh();
setStep('subject'); setStep('subject');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -145,16 +117,14 @@ export const EditDocumentForm = ({
}; };
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message, timezone, dateFormat } = data.meta; const { subject, message } = data.email;
try { try {
await sendDocument({ await completeDocument({
documentId: document.id, documentId: document.id,
meta: { email: {
subject, subject,
message, message,
timezone,
dateFormat,
}, },
}); });
@@ -176,8 +146,6 @@ export const EditDocumentForm = ({
} }
}; };
const currentDocumentFlow = documentFlow[step];
return ( return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}> <div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card <Card
@@ -190,47 +158,44 @@ export const EditDocumentForm = ({
</Card> </Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5"> <div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer <DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
className="lg:h-[calc(100vh-6rem)]" <DocumentFlowFormContainerHeader
onSubmit={(e) => e.preventDefault()} title={currentDocumentFlow.title}
> description={currentDocumentFlow.description}
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
>
<AddTitleFormPartial
key={recipients.length}
documentFlow={documentFlow.title}
recipients={recipients}
fields={fields}
document={document}
onSubmit={onAddTitleFormSubmit}
/> />
{step === 'signers' && (
<AddSignersFormPartial <AddSignersFormPartial
key={recipients.length} key={recipients.length}
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
document={document}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddSignersFormSubmit} onSubmit={onAddSignersFormSubmit}
/> />
)}
{step === 'fields' && (
<AddFieldsFormPartial <AddFieldsFormPartial
key={fields.length} key={fields.length}
documentFlow={documentFlow.fields} documentFlow={documentFlow.fields}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddFieldsFormSubmit} onSubmit={onAddFieldsFormSubmit}
/> />
)}
{step === 'subject' && (
<AddSubjectFormPartial <AddSubjectFormPartial
key={recipients.length}
documentFlow={documentFlow.subject} documentFlow={documentFlow.subject}
document={document} document={document}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddSubjectFormSubmit} onSubmit={onAddSubjectFormSubmit}
/> />
</Stepper> )}
</DocumentFlowFormContainer> </DocumentFlowFormContainer>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,7 @@ import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react'; import { ChevronLeft, Users2 } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
@@ -43,11 +43,11 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
const { documentData } = document; const { documentData } = document;
const [recipients, fields] = await Promise.all([ const [recipients, fields] = await Promise.all([
getRecipientsForDocument({ await getRecipientsForDocument({
documentId, documentId,
userId: user.id, userId: user.id,
}), }),
getFieldsForDocument({ await getFieldsForDocument({
documentId, documentId,
userId: user.id, userId: user.id,
}), }),

View File

@@ -1,189 +0,0 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { History } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
const FORM_ID = 'resend-email';
export type ResendDocumentActionItemProps = {
document: Document;
recipients: Recipient[];
};
export const ZResendDocumentFormSchema = z.object({
recipients: z.array(z.number()).min(1, {
message: 'You must select at least one item.',
}),
});
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
export const ResendDocumentActionItem = ({
document,
recipients,
}: ResendDocumentActionItemProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === session?.user?.id;
const isDisabled =
!isOwner ||
document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation();
const form = useForm<TResendDocumentFormSchema>({
resolver: zodResolver(ZResendDocumentFormSchema),
defaultValues: {
recipients: [],
},
});
const {
handleSubmit,
formState: { isSubmitting },
} = form;
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
await resendDocument({ documentId: document.id, recipients });
toast({
title: 'Document re-sent',
description: 'Your document has been re-sent successfully.',
duration: 5000,
});
setIsOpen(false);
} catch (err) {
toast({
title: 'Something went wrong',
description: 'This document could not be re-sent at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
};
return (
<>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle>
<h1 className="text-center text-xl">Who do you want to remind?</h1>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
<FormField
control={form.control}
name="recipients"
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem
key={recipient.id}
className="flex flex-row items-center justify-between gap-x-3"
>
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
})}
>
<StackAvatar
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
{recipient.email}
</FormLabel>
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
checkClassName="text-white"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
checked
? onChange([...value, recipient.id])
: onChange(value.filter((v) => v !== recipient.id))
}
/>
</FormControl>
</FormItem>
))}
</>
)}
/>
</form>
</Form>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogClose>
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
Send reminder
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -2,17 +2,13 @@
import Link from 'next/link'; import Link from 'next/link';
import { Download, Edit, Pencil } from 'lucide-react'; 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 { getFile } from '@documenso/lib/universal/upload/get-file'; import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
import type { Document, Recipient, User } from '@documenso/prisma/client'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DataTableActionButtonProps = { export type DataTableActionButtonProps = {
row: Document & { row: Document & {
@@ -23,7 +19,6 @@ export type DataTableActionButtonProps = {
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
const { toast } = useToast();
if (!session) { if (!session) {
return null; return null;
@@ -38,50 +33,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 onDownloadClick = async () => {
try {
let document: DocumentWithData | null = null;
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
}
const documentData = document?.documentData;
if (!documentData) {
return;
}
const documentBytes = await getFile(documentData);
const blob = new Blob([documentBytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
link.href = window.URL.createObjectURL(blob);
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
} catch (error) {
toast({
title: 'Something went wrong',
description: 'An error occurred while trying to download file.',
variant: 'destructive',
});
}
};
return match({ return match({
isOwner, isOwner,
isRecipient, isRecipient,
@@ -91,7 +42,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
isSigned, isSigned,
}) })
.with({ isOwner: true, isDraft: true }, () => ( .with({ isOwner: true, isDraft: true }, () => (
<Button className="w-32" asChild> <Button className="w-24" asChild>
<Link href={`/documents/${row.id}`}> <Link href={`/documents/${row.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" /> <Edit className="-ml-1 mr-2 h-4 w-4" />
Edit Edit
@@ -99,24 +50,23 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
</Button> </Button>
)) ))
.with({ isRecipient: true, isPending: true, isSigned: false }, () => ( .with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild> <Button className="w-24" asChild>
<Link href={`/sign/${recipient?.token}`}> <Link href={`/sign/${recipient?.token}`}>
<Pencil className="-ml-1 mr-2 h-4 w-4" /> <Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign Sign
</Link> </Link>
</Button> </Button>
)) ))
.with({ isPending: true, isSigned: true }, () => ( .otherwise(() => (
<Button className="w-32" disabled={true}> <DocumentShareButton
<Pencil className="-ml-1 mr-2 inline h-4 w-4" /> documentId={row.id}
Sign token={recipient?.token}
trigger={({ loading }) => (
<Button className="w-24" loading={loading}>
{!loading && <Share className="-ml-1 mr-2 h-4 w-4" />}
Share
</Button> </Button>
)) )}
.with({ isComplete: true }, () => ( />
<Button className="w-32" onClick={onDownloadClick}> ));
<Download className="-ml-1 mr-2 inline h-4 w-4" />
Download
</Button>
))
.otherwise(() => <div></div>);
}; };

View File

@@ -8,6 +8,7 @@ import {
Copy, Copy,
Download, Download,
Edit, Edit,
History,
Loader, Loader,
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
@@ -18,9 +19,8 @@ import {
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import type { Document, Recipient, User } from '@documenso/prisma/client'; import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { import {
@@ -31,8 +31,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu'; } from '@documenso/ui/primitives/dropdown-menu';
import { ResendDocumentActionItem } from './_action-items/resend-document'; import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
import { DeleteDocumentDialog } from './delete-document-dialog';
import { DuplicateDocumentDialog } from './duplicate-document-dialog'; import { DuplicateDocumentDialog } from './duplicate-document-dialog';
export type DataTableActionDropdownProps = { export type DataTableActionDropdownProps = {
@@ -60,7 +59,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
// const isPending = row.status === DocumentStatus.PENDING; // const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner; const isDocumentDeletable = isOwner && row.status === DocumentStatus.DRAFT;
const onDownloadClick = async () => { const onDownloadClick = async () => {
let document: DocumentWithData | null = null; let document: DocumentWithData | null = null;
@@ -88,18 +87,15 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
}); });
const link = window.document.createElement('a'); const link = window.document.createElement('a');
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
link.href = window.URL.createObjectURL(blob); link.href = window.URL.createObjectURL(blob);
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; link.download = row.title || 'document.pdf';
link.click(); link.click();
window.URL.revokeObjectURL(link.href); window.URL.revokeObjectURL(link.href);
}; };
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
@@ -145,16 +141,19 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DropdownMenuLabel>Share</DropdownMenuLabel> <DropdownMenuLabel>Share</DropdownMenuLabel>
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} /> <DropdownMenuItem disabled>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
<DocumentShareButton <DocumentShareButton
documentId={row.id} documentId={row.id}
token={isOwner ? undefined : recipient?.token} token={recipient?.token}
trigger={({ loading, disabled }) => ( trigger={({ loading, disabled }) => (
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}> <DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
<div className="flex items-center"> <div className="flex items-center">
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />} {loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
Share Signing Card Share
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
@@ -162,10 +161,8 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
</DropdownMenuContent> </DropdownMenuContent>
{isDocumentDeletable && ( {isDocumentDeletable && (
<DeleteDocumentDialog <DeleteDraftDocumentDialog
id={row.id} id={row.id}
status={row.status}
documentTitle={row.title}
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
/> />

View File

@@ -6,9 +6,8 @@ import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { FindResultSet } from '@documenso/lib/types/find-result-set'; import { FindResultSet } from '@documenso/lib/types/find-result-set';
import type { Document, Recipient, User } from '@documenso/prisma/client'; import { Document, Recipient, User } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -75,9 +74,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
}, },
{ {
header: 'Actions', header: 'Actions',
cell: ({ row }) => cell: ({ row }) => (
(!row.original.deletedAt ||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<DataTableActionButton row={row.original} /> <DataTableActionButton row={row.original} />
<DataTableActionDropdown row={row.original} /> <DataTableActionDropdown row={row.original} />

View File

@@ -1,129 +0,0 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteDraftDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
status: DocumentStatus;
documentTitle: string;
};
export const DeleteDocumentDialog = ({
id,
open,
onOpenChange,
status,
documentTitle,
}: DeleteDraftDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteDocument, isLoading } = trpcReact.document.deleteDocument.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Document deleted',
description: `"${documentTitle}" has been successfully deleted`,
duration: 5000,
});
onOpenChange(false);
},
});
useEffect(() => {
if (open) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
}, [open, status]);
const onDelete = async () => {
try {
await deleteDocument({ id, status });
} catch {
toast({
title: 'Something went wrong',
description: 'This document could not be deleted at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
};
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === 'delete');
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your document will be
permanently deleted.
</DialogDescription>
</DialogHeader>
{status !== DocumentStatus.DRAFT && (
<div className="mt-4">
<Input
type="text"
value={inputValue}
onChange={onInputChange}
placeholder="Type 'delete' to confirm"
/>
</div>
)}
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button
type="button"
loading={isLoading}
onClick={onDelete}
disabled={!isDeleteEnabled}
variant="destructive"
className="flex-1"
>
Delete
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

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