Compare commits

..

4 Commits

Author SHA1 Message Date
Mythie
c4b4e8e664 Merge branch 'main' into release 2023-11-16 16:48:50 +11:00
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
315 changed files with 5680 additions and 13085 deletions

View File

@@ -2,11 +2,6 @@
NEXTAUTH_URL="http://localhost:3000"
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]]
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
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
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
NEXT_PRIVATE_UPLOAD_ENDPOINT="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.
NEXT_PRIVATE_UPLOAD_REGION="unknown"
NEXT_PRIVATE_UPLOAD_REGION=
# 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.
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.
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password"
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
# [[SMTP]]
# 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_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID=
# [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags.
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.
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
# [[REDIS]]

View File

@@ -1,10 +1,11 @@
name: 'Bug Report'
labels: ['bug']
name: "Bug Report"
labels: ["bug"]
description: Create a bug report to help us improve
body:
- type: markdown
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.
- type: textarea
attributes:

View File

@@ -1,9 +1,9 @@
name: 'Feature Request'
name: "Feature Request"
description: Suggest a new idea or enhancement for this project
body:
- type: markdown
attributes:
value: Please provide a clear and concise title for your feature request
value: Please provide a clear and concise title for your feature request
- type: textarea
attributes:
label: Feature Description
@@ -32,4 +32,4 @@ body:
- label: I have provided a detailed description of the requested feature.
- label: I have explained the use case or scenario for this feature.
- label: I have included any relevant technical details or design suggestions.
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
- label: I understand that this is a suggestion and that there is no guarantee of implementation.

View File

@@ -1,4 +1,4 @@
name: 'General Improvement'
name: "General Improvement"
description: Suggest a minor enhancement or improvement for this project
body:
- type: markdown
@@ -32,4 +32,4 @@ body:
- label: I have provided a clear description of the improvement being suggested.
- label: I have explained the rationale behind this improvement.
- label: I have included any relevant technical details or design suggestions.
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
- label: I understand that this is a suggestion and that there is no guarantee of implementation.

View File

@@ -4,29 +4,29 @@ updates:
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'weekly'
target-branch: 'main'
interval: "weekly"
target-branch: "main"
labels:
- 'ci dependencies'
- 'ci'
- "ci dependencies"
- "ci"
open-pull-requests-limit: 0
- package-ecosystem: 'npm'
directory: '/apps/marketing'
- package-ecosystem: "npm"
directory: "/apps/marketing"
schedule:
interval: 'weekly'
target-branch: 'main'
interval: "weekly"
target-branch: "main"
labels:
- 'npm dependencies'
- 'frontend'
- "npm dependencies"
- "frontend"
open-pull-requests-limit: 0
- package-ecosystem: 'npm'
directory: '/apps/web'
- package-ecosystem: "npm"
directory: "/apps/web"
schedule:
interval: 'weekly'
target-branch: 'main'
interval: "weekly"
target-branch: "main"
labels:
- 'npm dependencies'
- 'frontend'
- "npm dependencies"
- "frontend"
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:
push:
branches: ['main']
branches: [ "main" ]
pull_request:
branches: ['main']
branches: [ "main" ]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
@@ -14,17 +14,17 @@ env:
HUSKY: 0
jobs:
build_app:
name: Build App
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
fetch-depth: 2
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v3
with:
node-version: 18
cache: npm
@@ -37,15 +37,3 @@ jobs:
- name: 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:
workflow_dispatch:
push:
branches: ['main']
branches: [ "main" ]
pull_request:
branches: ['main']
branches: [ "main" ]
jobs:
analyze:
@@ -19,30 +19,30 @@ jobs:
strategy:
fail-fast: true
matrix:
language: ['javascript']
language: [ 'javascript' ]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Checkout repository
uses: actions/checkout@v3
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- uses: actions/setup-node@v3
with:
node-version: 18
cache: npm
- name: Install Dependencies
run: npm ci
- name: Install Dependencies
run: npm ci
- name: Copy env
run: cp .env.example .env
- name: Copy env
run: cp .env.example .env
- name: Build Documenso
run: npm run build
- name: Build Documenso
run: npm run build
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

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

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:
pull_request_target:
@@ -9,54 +9,13 @@ on:
- synchronize
permissions:
pull-requests: write
pull-requests: read
jobs:
validate-pr:
name: Validate PR title
runs-on: ubuntu-latest
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
id: lint_pr_title
env:
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,13 +1,10 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
"source.fixAll.eslint": true
},
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
"javascript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.useAliasesForRenames": false,
"typescript.enablePromptUseWorkspaceTsdk": true,
"files.eol": "\n",
"editor.tabSize": 2,
"editor.insertSpaces": true
"typescript.enablePromptUseWorkspaceTsdk": true
}

View File

@@ -13,9 +13,9 @@
·
<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>
@@ -115,12 +115,10 @@ To run Documenso locally, you will need
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.
After forking the repository, clone it to your local device by using the following command:
1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
```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.
@@ -141,25 +139,21 @@ npm run d
1. **App** - http://localhost:3000
2. **Incoming Mail Access** - http://localhost:9000
3. **Database Connection Details**
3. **Database Connection Details**
- **Port**: 54320
- **Connection**: Use your favorite database client to connect using the provided port.
4. **S3 Storage Dashboard** - http://localhost:9001
## Developer Setup
### Manual Setup
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.
After forking the repository, clone it to your local device by using the following command:
1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
```sh
git clone https://github.com/<your-username>/documenso
git clone https://github.com/documenso/documenso
```
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)
### 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 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 run build:web
npm run:build:web
npm run prisma:migrate-deploy
```
@@ -284,16 +272,12 @@ WantedBy=multi-user.target
### 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
[![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
### I'm not receiving any emails when using the developer quickstart.

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 */
const fs = require('fs');
const path = require('path');
const { withContentlayer } = require('next-contentlayer');
@@ -11,31 +10,16 @@ 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} */
const config = {
experimental: {
serverActionsBodySizeLimit: '10mb',
outputFileTracingRoot: path.join(__dirname, '../../'),
serverActions: {
bodySizeLimit: '50mb',
},
},
reactStrictMode: true,
transpilePackages: [
'@documenso/assets',
'@documenso/lib',
'@documenso/tailwind-config',
'@documenso/trpc',
'@documenso/ui',
],
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
env: {
NEXT_PUBLIC_PROJECT: 'marketing',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
},
modularizeImports: {
'lucide-react': {

View File

@@ -1,6 +1,6 @@
{
"name": "@documenso/marketing",
"version": "1.2.3",
"version": "0.1.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -13,7 +13,6 @@
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
},
"dependencies": {
"@documenso/assets": "*",
"@documenso/lib": "*",
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
@@ -24,8 +23,8 @@
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "14.0.3",
"next-auth": "4.24.5",
"next": "14.0.0",
"next-auth": "4.24.3",
"next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0",
@@ -36,7 +35,7 @@
"react-hook-form": "^7.43.9",
"react-icons": "^4.11.0",
"recharts": "^2.7.2",
"sharp": "0.33.1",
"sharp": "0.32.5",
"typescript": "5.2.2",
"zod": "^3.22.4"
},
@@ -44,13 +43,5 @@
"@types/node": "20.1.0",
"@types/react": "18.2.18",
"@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_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_WEBHOOK_SECRET: string;

View File

@@ -29,7 +29,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
return (
<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',
})}
>
@@ -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" />
</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" />
</div>

View File

@@ -3,7 +3,7 @@
import { DateTime } from 'luxon';
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';
export type MonthlyNewUsersChartProps = {
@@ -22,7 +22,7 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
return (
<div className={cn('flex flex-col', className)}>
<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 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 { 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';
export type MonthlyTotalUsersChartProps = {
@@ -22,7 +22,7 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
return (
<div className={cn('flex flex-col', className)}>
<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 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

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

View File

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

View File

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

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

@@ -5,13 +5,14 @@ import type { HTMLAttributes } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { FaXTwitter } from 'react-icons/fa6';
import { LiaDiscord } from 'react-icons/lia';
import { LuGithub } from 'react-icons/lu';
import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
export type FooterProps = HTMLAttributes<HTMLDivElement>;
@@ -31,15 +32,16 @@ const FOOTER_LINKS = [
{ href: 'https://status.documenso.com', text: 'Status', 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' },
];
export const Footer = ({ className, ...props }: FooterProps) => {
const { setTheme } = useTheme();
return (
<div className={cn('border-t py-12', className)} {...props}>
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
<div className="flex-shrink-0">
<div>
<Link href="/">
<Image
src={LogoImage}
@@ -64,26 +66,34 @@ export const Footer = ({ className, ...props }: FooterProps) => {
</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) => (
<Link
key={index}
href={link.href}
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>
))}
</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">
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
</p>
<div className="flex flex-wrap">
<ThemeSwitcher />
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
<button type="button" className="text-muted-foreground" onClick={() => setTheme('light')}>
<Sun className="h-5 w-5" />
<span className="sr-only">Light</span>
</button>
<button type="button" className="text-muted-foreground" onClick={() => setTheme('dark')}>
<Moon className="h-5 w-5" />
<span className="sr-only">Dark</span>
</button>
</div>
</div>
</div>

View File

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

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

@@ -6,9 +6,8 @@ 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 type { Signature } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import { DocumentStatus, Signature } from '@documenso/prisma/client';
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';

View File

@@ -1,7 +1,6 @@
'use client';
import type { HTMLAttributes, KeyboardEvent } from 'react';
import { useMemo, useState } from 'react';
import { HTMLAttributes, KeyboardEvent, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
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 { STEP } from '../constants';
import { FormErrorMessage } from '../form/form-error-message';
const ZWidgetFormSchema = z
@@ -50,16 +48,13 @@ const ZWidgetFormSchema = z
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
type StepKeys = keyof typeof STEP;
type StepValues = (typeof STEP)[StepKeys];
export type WidgetProps = HTMLAttributes<HTMLDivElement>;
export const Widget = ({ className, children, ...props }: WidgetProps) => {
const { toast } = useToast();
const event = usePlausible();
const [step, setStep] = useState<StepValues>(STEP.EMAIL);
const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL');
const [showSigningDialog, setShowSigningDialog] = useState(false);
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
@@ -86,28 +81,28 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
const signatureText = watch('signatureText');
const stepsRemaining = useMemo(() => {
if (step === STEP.NAME) {
if (step === 'NAME') {
return 2;
}
if (step === STEP.EMAIL) {
return 3;
if (step === 'SIGN') {
return 1;
}
return 1;
return 3;
}, [step]);
const onNextStepClick = () => {
if (step === STEP.EMAIL) {
setStep(STEP.NAME);
if (step === 'EMAIL') {
setStep('NAME');
setTimeout(() => {
document.querySelector<HTMLElement>('#name')?.focus();
}, 0);
}
if (step === STEP.NAME) {
setStep(STEP.SIGN);
if (step === 'NAME') {
setStep('SIGN');
setTimeout(() => {
document.querySelector<HTMLElement>('#signatureText')?.focus();
@@ -231,7 +226,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
type="button"
className="bg-primary h-full w-14 rounded"
disabled={!field.value || !!errors.email?.message}
onClick={() => step === STEP.EMAIL && onNextStepClick()}
onClick={() => step === 'EMAIL' && onNextStepClick()}
>
Next
</Button>
@@ -243,7 +238,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<FormErrorMessage error={errors.email} className="mt-1" />
</motion.div>
{(step === STEP.NAME || step === STEP.SIGN) && (
{(step === 'NAME' || step === 'SIGN') && (
<motion.div
key="name"
className="mt-4"

View File

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

View File

@@ -1,4 +1,4 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { NextApiRequest, NextApiResponse } from 'next';
import { randomBytes } from 'crypto';
import { buffer } from 'micro';
@@ -6,8 +6,7 @@ import { buffer } from 'micro';
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
import { redis } from '@documenso/lib/server-only/redis';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { updateFile } from '@documenso/lib/universal/upload/update-file';
import { prisma } from '@documenso/prisma';

View File

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

View File

@@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const fs = require('fs');
const path = require('path');
const { version } = require('./package.json');
@@ -11,35 +10,24 @@ 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} */
const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'),
serverActions: {
bodySizeLimit: '50mb',
},
serverActionsBodySizeLimit: '50mb',
},
reactStrictMode: true,
transpilePackages: [
'@documenso/assets',
'@documenso/ee',
'@documenso/lib',
'@documenso/prisma',
'@documenso/tailwind-config',
'@documenso/trpc',
'@documenso/ui',
'@documenso/email',
],
env: {
APP_VERSION: version,
NEXT_PUBLIC_PROJECT: 'web',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
},
modularizeImports: {
'lucide-react': {

View File

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

View File

@@ -9,6 +9,7 @@ import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
Form,
FormControl,
@@ -18,7 +19,6 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
import { useToast } from '@documenso/ui/primitives/use-toast';
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">
<FormLabel className="text-muted-foreground">Roles</FormLabel>
<FormControl>
<MultiSelectCombobox
<Combobox
listValues={roles}
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 { 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 { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -19,7 +19,7 @@ type UserData = {
name: string | null;
email: string;
roles: Role[];
Subscription?: SubscriptionLite[] | null;
Subscription?: SubscriptionLite | null;
Document: DocumentLite[];
};
@@ -35,16 +35,9 @@ type UsersDataTableProps = {
totalPages: number;
perPage: number;
page: number;
individualPriceIds: string[];
};
export const UsersDataTable = ({
users,
totalPages,
perPage,
page,
individualPriceIds,
}: UsersDataTableProps) => {
export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const [searchString, setSearchString] = useState('');
@@ -107,13 +100,7 @@ export const UsersDataTable = ({
{
header: 'Subscription',
accessorKey: 'subscription',
cell: ({ row }) => {
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
individualPriceIds.includes(sub.priceId),
);
return foundIndividualSubscription?.status ?? 'NONE';
},
cell: ({ row }) => row.original.Subscription?.status ?? 'NONE',
},
{
header: 'Documents',

View File

@@ -1,16 +1,8 @@
'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';
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 });
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 { search } from './fetch-users.actions';
@@ -16,23 +14,12 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const perPage = Number(searchParams.perPage) || 10;
const searchString = searchParams.search || '';
const [{ users, totalPages }, individualPrices] = await Promise.all([
search(searchString, page, perPage),
getPricesByType('individual'),
]);
const individualPriceIds = individualPrices.map((price) => price.id);
const { users, totalPages } = await search(searchString, page, perPage);
return (
<div>
<h2 className="text-4xl font-semibold">Manage users</h2>
<UsersDataTable
users={users}
individualPriceIds={individualPriceIds}
totalPages={totalPages}
page={page}
perPage={perPage}
/>
<UsersDataTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
</div>
);
}

View File

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

View File

@@ -43,11 +43,11 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
const { documentData } = document;
const [recipients, fields] = await Promise.all([
getRecipientsForDocument({
await getRecipientsForDocument({
documentId,
userId: user.id,
}),
getFieldsForDocument({
await getFieldsForDocument({
documentId,
userId: user.id,
}),

View File

@@ -4,7 +4,6 @@ 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';
@@ -55,14 +54,11 @@ 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);

View File

@@ -12,7 +12,6 @@ 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 { useToast } from '@documenso/ui/primitives/use-toast';
export type DataTableActionButtonProps = {
row: Document & {
@@ -23,7 +22,6 @@ export type DataTableActionButtonProps = {
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
if (!session) {
return null;
@@ -39,47 +37,38 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const onDownloadClick = async () => {
try {
let document: DocumentWithData | null = null;
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',
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
});
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',
} 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');
link.href = window.URL.createObjectURL(blob);
link.download = row.title || 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
};
return match({

View File

@@ -32,7 +32,7 @@ import {
} from '@documenso/ui/primitives/dropdown-menu';
import { ResendDocumentActionItem } from './_action-items/resend-document';
import { DeleteDocumentDialog } from './delete-document-dialog';
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
export type DataTableActionDropdownProps = {
@@ -60,7 +60,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
// const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner;
const isDocumentDeletable = isOwner && row.status === DocumentStatus.DRAFT;
const onDownloadClick = async () => {
let document: DocumentWithData | null = null;
@@ -88,10 +88,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
});
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.download = row.title || 'document.pdf';
link.click();
@@ -99,7 +98,6 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
};
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger>
@@ -162,10 +160,8 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
</DropdownMenuContent>
{isDocumentDeletable && (
<DeleteDocumentDialog
<DeleteDraftDocumentDialog
id={row.id}
status={row.status}
documentTitle={row.title}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>

View File

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

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>
);
};

View File

@@ -12,38 +12,43 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteTemplateDialogProps = {
type DeleteDraftDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
export const DeleteDraftDocumentDialog = ({
id,
open,
onOpenChange,
}: DeleteDraftDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: deleteTemplate, isLoading } = trpcReact.template.deleteTemplate.useMutation({
onSuccess: () => {
router.refresh();
const { mutateAsync: deleteDocument, isLoading } =
trpcReact.document.deleteDraftDocument.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Template deleted',
description: 'Your template has been successfully deleted.',
duration: 5000,
});
toast({
title: 'Document deleted',
description: 'Your document has been successfully deleted.',
duration: 5000,
});
onOpenChange(false);
},
});
onOpenChange(false);
},
});
const onDeleteTemplate = async () => {
const onDraftDelete = async () => {
try {
await deleteTemplate({ id });
await deleteDocument({ id });
} catch {
toast({
title: 'Something went wrong',
description: 'This template could not be deleted at this time. Please try again.',
description: 'This document could not be deleted at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
@@ -54,10 +59,10 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to delete this template?</DialogTitle>
<DialogTitle>Do you want to delete this document?</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your template will be
Please note that this action is irreversible. Once confirmed, your document will be
permanently deleted.
</DialogDescription>
</DialogHeader>
@@ -73,7 +78,7 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={onDeleteTemplate} className="flex-1">
<Button type="button" loading={isLoading} onClick={onDraftDelete} className="flex-1">
Delete
</Button>
</div>

View File

@@ -41,7 +41,6 @@ export const DuplicateDocumentDialog = ({
trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => {
router.push(`/documents/${newId}`);
toast({
title: 'Document Duplicated',
description: 'Your document has been successfully duplicated.',

View File

@@ -1,15 +1,13 @@
'use client';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { TRPCClientError } from '@documenso/trpc/client';
@@ -24,9 +22,6 @@ export type UploadDocumentProps = {
export const UploadDocument = ({ className }: UploadDocumentProps) => {
const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession();
const { toast } = useToast();
@@ -36,16 +31,6 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
const disabledMessage = useMemo(() => {
if (remaining.documents === 0) {
return 'You have reached your document limit.';
}
if (!session?.user.emailVerified) {
return 'Verify your email to upload documents.';
}
}, [remaining.documents, session?.user.emailVerified]);
const onFileDrop = async (file: File) => {
try {
setIsLoading(true);
@@ -68,12 +53,6 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
duration: 5000,
});
analytics.capture('App: Document Uploaded', {
userId: session?.user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
router.push(`/documents/${id}`);
} catch (error) {
console.error(error);
@@ -100,8 +79,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
<div className={cn('relative', className)}>
<DocumentDropzone
className="min-h-[40vh]"
disabled={remaining.documents === 0 || !session?.user.emailVerified}
disabledMessage={disabledMessage}
disabled={remaining.documents === 0}
onDrop={onFileDrop}
/>

View File

@@ -9,7 +9,6 @@ import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
import { NextAuthProvider } from '~/providers/next-auth';
@@ -31,7 +30,6 @@ export default async function AuthenticatedDashboardLayout({
return (
<NextAuthProvider session={session}>
<LimitsProvider>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
<Header user={user} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
import { Button } from '@documenso/ui/primitives/button';

View File

@@ -1,13 +1,46 @@
'use server';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import {
getStripeCustomerByEmail,
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export const createBillingPortal = async () => {
const { user } = await getRequiredServerComponentSession();
const { stripeCustomer } = await getStripeCustomerByUser(user);
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
let stripeCustomer: Stripe.Customer | null = null;
// Find the Stripe customer for the current user subscription.
if (existingSubscription) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer for subscription');
}
}
// Fallback to check if a Stripe customer already exists for the current user email.
if (!stripeCustomer) {
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
// Create a Stripe customer if it does not exist for the current user.
if (!stripeCustomer) {
stripeCustomer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
});
}
return getPortalSession({
customerId: stripeCustomer.id,

View File

@@ -1,36 +1,55 @@
'use server';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import {
getStripeCustomerByEmail,
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export type CreateCheckoutOptions = {
priceId: string;
};
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
const session = await getRequiredServerComponentSession();
const { user } = await getRequiredServerComponentSession();
const { user, stripeCustomer } = await getStripeCustomerByUser(session.user);
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
const existingSubscriptions = await getSubscriptionsByUserId({ userId: user.id });
let stripeCustomer: Stripe.Customer | null = null;
const foundSubscription = existingSubscriptions.find(
(subscription) =>
subscription.priceId === priceId &&
subscription.periodEnd &&
subscription.periodEnd >= new Date(),
);
// Find the Stripe customer for the current user subscription.
if (existingSubscription) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer for subscription');
}
if (foundSubscription) {
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
}
// Fallback to check if a Stripe customer already exists for the current user email.
if (!stripeCustomer) {
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
// Create a Stripe customer if it does not exist for the current user.
if (!stripeCustomer) {
await createCustomer({
user,
});
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
return getCheckoutSession({
customerId: stripeCustomer.id,
priceId,

View File

@@ -2,15 +2,12 @@ import { redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { type Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
import { SubscriptionStatus } from '@documenso/prisma/client';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { LocaleDate } from '~/components/formatter/locale-date';
@@ -18,7 +15,7 @@ import { BillingPlans } from './billing-plans';
import { BillingPortalButton } from './billing-portal-button';
export default async function BillingSettingsPage() {
let { user } = await getRequiredServerComponentSession();
const { user } = await getRequiredServerComponentSession();
const isBillingEnabled = await getServerComponentFlag('app_billing');
@@ -27,40 +24,24 @@ export default async function BillingSettingsPage() {
redirect('/settings/profile');
}
if (!user.customerId) {
user = await getStripeCustomerByUser(user).then((result) => result.user);
}
const [subscriptions, prices, individualPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ type: 'individual' }),
getPricesByType('individual'),
const [subscription, prices] = await Promise.all([
getSubscriptionByUserId({ userId: user.id }),
getPricesByInterval(),
]);
const individualPriceIds = individualPrices.map(({ id }) => id);
let subscriptionProduct: Stripe.Product | null = null;
const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
individualPriceIds.includes(priceId),
);
const subscription =
individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
individualUserSubscriptions[0];
if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
() => null,
);
}
const isMissingOrInactiveOrFreePlan =
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
return (
<div>
<h3 className="text-2xl font-semibold">Billing</h3>
<h3 className="text-lg font-medium">Billing</h3>
<div className="text-muted-foreground mt-2 text-sm">
{isMissingOrInactiveOrFreePlan && (

View File

@@ -1,5 +1,19 @@
import { redirect } from 'next/navigation';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
export default function PasswordSettingsPage() {
redirect('/settings/security');
import { PasswordForm } from '~/components/forms/password';
export default async function PasswordSettingsPage() {
const { user } = await getRequiredServerComponentSession();
return (
<div>
<h3 className="text-lg font-medium">Password</h3>
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
<hr className="my-4" />
<PasswordForm user={user} className="max-w-xl" />
</div>
);
}

View File

@@ -7,7 +7,7 @@ export default async function ProfileSettingsPage() {
return (
<div>
<h3 className="text-2xl font-semibold">Profile</h3>
<h3 className="text-lg font-medium">Profile</h3>
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>

View File

@@ -1,46 +0,0 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
import { PasswordForm } from '~/components/forms/password';
export default async function SecuritySettingsPage() {
const { user } = await getRequiredServerComponentSession();
return (
<div>
<h3 className="text-2xl font-semibold">Security</h3>
<p className="text-muted-foreground mt-2 text-sm">
Here you can manage your password and security settings.
</p>
<hr className="my-4" />
<PasswordForm user={user} className="max-w-xl" />
<hr className="mb-4 mt-8" />
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
<p className="text-muted-foreground mt-2 text-sm">
Add and manage your two factor security settings to add an extra layer of security to your
account!
</p>
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Two-factor methods</h5>
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
</div>
{user.twoFactorEnabled && (
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Recovery methods</h5>
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
</div>
)}
</div>
);
}

View File

@@ -1,156 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EditTemplateFormProps = {
className?: string;
user: User;
template: Template;
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
};
type EditTemplateStep = 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
export const EditTemplateForm = ({
className,
template,
recipients,
fields,
user: _user,
documentData,
}: EditTemplateFormProps) => {
const { toast } = useToast();
const router = useRouter();
const [step, setStep] = useState<EditTemplateStep>('signers');
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
signers: {
title: 'Add Placeholders',
description: 'Add all relevant placeholders for each recipient.',
stepIndex: 1,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 2,
},
};
const currentDocumentFlow = documentFlow[step];
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
await addTemplateSigners({
templateId: template.id,
signers: data.signers,
});
router.refresh();
setStep('fields');
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try {
await addTemplateFields({
templateId: template.id,
fields: data.fields,
});
toast({
title: 'Template saved',
description: 'Your templates has been saved successfully.',
duration: 5000,
});
router.push('/templates');
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
};
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer key={documentData.id} documentData={documentData} />
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()}
>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
>
<AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
onSubmit={onAddTemplatePlaceholderFormSubmit}
/>
<AddTemplateFieldsFormPartial
key={fields.length}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
/>
</Stepper>
</DocumentFlowFormContainer>
</div>
</div>
);
};

View File

@@ -1,81 +0,0 @@
import React from 'react';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { TemplateType } from '~/components/formatter/template-type';
import { EditTemplateForm } from './edit-template';
export type TemplatePageProps = {
params: {
id: string;
};
};
export default async function TemplatePage({ params }: TemplatePageProps) {
const { id } = params;
const templateId = Number(id);
if (!templateId || Number.isNaN(templateId)) {
redirect('/documents');
}
const { user } = await getRequiredServerComponentSession();
const template = await getTemplateById({
id: templateId,
userId: user.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
redirect('/documents');
}
const { templateDocumentData } = template;
const [templateRecipients, templateFields] = await Promise.all([
getRecipientsForTemplate({
templateId,
userId: user.id,
}),
getFieldsForTemplate({
templateId,
userId: user.id,
}),
]);
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{template.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
</div>
<EditTemplateForm
className="mt-8"
template={template}
user={user}
recipients={templateRecipients}
fields={templateFields}
documentData={templateDocumentData}
/>
</div>
);
}

View File

@@ -1,79 +0,0 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react';
import { useSession } from 'next-auth/react';
import type { Template } from '@documenso/prisma/client';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { DeleteTemplateDialog } from './delete-template-dialog';
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
export type DataTableActionDropdownProps = {
row: Template;
};
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
if (!session) {
return null;
}
const isOwner = row.userId === session.user.id;
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!isOwner} asChild>
<Link href={`/templates/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{/* <DropdownMenuItem disabled={!isOwner} onClick={async () => onDuplicateButtonClick(row.id)}> */}
<DropdownMenuItem disabled={!isOwner} onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem disabled={!isOwner} onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
<DuplicateTemplateDialog
id={row.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>
<DeleteTemplateDialog
id={row.id}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>
</DropdownMenu>
);
};

View File

@@ -1,138 +0,0 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader, Plus } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Template } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { LocaleDate } from '~/components/formatter/locale-date';
import { TemplateType } from '~/components/formatter/template-type';
import { DataTableActionDropdown } from './data-table-action-dropdown';
import { DataTableTitle } from './data-table-title';
type TemplatesDataTableProps = {
templates: Template[];
perPage: number;
page: number;
totalPages: number;
};
export const TemplatesDataTable = ({
templates,
perPage,
page,
totalPages,
}: TemplatesDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const router = useRouter();
const { toast } = useToast();
const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({});
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
const onUseButtonClick = async (templateId: number) => {
try {
const { id } = await createDocumentFromTemplate({
templateId,
});
toast({
title: 'Document created',
description: 'Your document has been created from the template successfully.',
duration: 5000,
});
router.push(`/documents/${id}`);
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while creating document from template.',
variant: 'destructive',
});
}
};
return (
<div className="relative">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
cell: ({ row }) => <DataTableTitle row={row.original} />,
},
{
header: 'Type',
accessorKey: 'type',
cell: ({ row }) => <TemplateType type={row.original.type} />,
},
{
header: 'Actions',
accessorKey: 'actions',
cell: ({ row }) => {
const isRowLoading = loadingStates[row.original.id];
return (
<div className="flex items-center gap-x-4">
<Button
disabled={isRowLoading}
loading={isRowLoading}
onClick={async () => {
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
await onUseButtonClick(row.original.id);
setLoadingStates((prev) => ({ ...prev, [row.original.id]: false }));
}}
>
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
Use Template
</Button>
<DataTableActionDropdown row={row.original} />
</div>
);
},
},
]}
data={templates}
perPage={perPage}
currentPage={page}
totalPages={totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};

View File

@@ -1,26 +0,0 @@
import Link from 'next/link';
import { useSession } from 'next-auth/react';
import { Template } from '@documenso/prisma/client';
export type DataTableTitleProps = {
row: Template;
};
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
const { data: session } = useSession();
if (!session) {
return null;
}
return (
<Link
href={`/templates/${row.id}`}
className="block max-w-[10rem] cursor-pointer truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}
</Link>
);
};

View File

@@ -1,87 +0,0 @@
import { useRouter } from 'next/navigation';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DuplicateTemplateDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DuplicateTemplateDialog = ({
id,
open,
onOpenChange,
}: DuplicateTemplateDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: duplicateTemplate, isLoading } =
trpcReact.template.duplicateTemplate.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Template duplicated',
description: 'Your template has been duplicated successfully.',
duration: 5000,
});
onOpenChange(false);
},
});
const onDuplicate = async () => {
try {
await duplicateTemplate({
templateId: id,
});
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while duplicating template.',
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to duplicate this template?</DialogTitle>
<DialogDescription className="pt-2">Your template will be duplicated.</DialogDescription>
</DialogHeader>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={onDuplicate} className="flex-1">
Duplicate
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,17 +0,0 @@
import { Bird } from 'lucide-react';
export const EmptyTemplateState = () => {
return (
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
<Bird className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
<h3 className="text-lg font-semibold">We're all empty</h3>
<p className="mt-2 max-w-[50ch]">
You have not yet created any templates. To create a template please upload one.
</p>
</div>
</div>
);
};

View File

@@ -1,228 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { FilePlus, X } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZCreateTemplateFormSchema = z.object({
name: z.string(),
});
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
export const NewTemplateDialog = () => {
const router = useRouter();
const { data: session } = useSession();
const { toast } = useToast();
const form = useForm<TCreateTemplateFormSchema>({
defaultValues: {
name: '',
},
resolver: zodResolver(ZCreateTemplateFormSchema),
});
const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } =
trpc.template.createTemplate.useMutation();
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
const onFileDrop = async (file: File) => {
try {
const arrayBuffer = await file.arrayBuffer();
const base64String = base64.encode(new Uint8Array(arrayBuffer));
setUploadedFile({
file,
fileBase64: `data:application/pdf;base64,${base64String}`,
});
if (!form.getValues('name')) {
form.setValue('name', file.name);
}
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
const onSubmit = async (values: TCreateTemplateFormSchema) => {
if (!uploadedFile) {
return;
}
const file: File = uploadedFile.file;
try {
const { type, data } = await putFile(file);
const { id: templateDocumentDataId } = await createDocumentData({
type,
data,
});
const { id } = await createTemplate({
title: values.name ? values.name : file.name,
templateDocumentDataId,
});
toast({
title: 'Template document uploaded',
description:
'Your document has been uploaded successfully. You will be redirected to the template page.',
duration: 5000,
});
setShowNewTemplateDialog(false);
void router.push(`/templates/${id}`);
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
const resetForm = () => {
if (form.getValues('name') === uploadedFile?.file.name) {
form.reset();
}
setUploadedFile(null);
};
useEffect(() => {
if (!showNewTemplateDialog) {
form.reset();
}
}, [form, showNewTemplateDialog]);
return (
<Dialog open={showNewTemplateDialog} onOpenChange={setShowNewTemplateDialog}>
<DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
New Template
</Button>
</DialogTrigger>
<DialogContent className="w-full max-w-xl">
<DialogHeader>
<DialogTitle className="mb-4">New Template</DialogTitle>
</DialogHeader>
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name your template</FormLabel>
<FormControl>
<Input id="email" type="text" className="bg-background mt-1.5" {...field} />
</FormControl>
<FormDescription>
<span className="text-muted-foreground text-xs">
Leave this empty if you would like to use your document's name for the
template
</span>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<Label htmlFor="template">Upload a Document</Label>
<div className="my-3">
{uploadedFile ? (
<Card gradient className="h-[40vh]">
<CardContent className="flex h-full flex-col items-center justify-center p-2">
<button
onClick={() => resetForm()}
title="Remove Template"
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
>
<X className="h-6 w-6" />
<span className="sr-only">Remove Template</span>
</button>
<div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm">
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
</div>
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
Uploaded Document
</p>
<span className="text-muted-foreground/80 mt-1 text-sm">
{uploadedFile.file.name}
</span>
</CardContent>
</Card>
) : (
<DocumentDropzone
className="mt-1.5 h-[40vh]"
onDrop={onFileDrop}
type="template"
/>
)}
</div>
</div>
<div className="flex w-full justify-end">
<Button loading={isCreatingTemplate} type="submit">
Create Template
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,52 +0,0 @@
import React from 'react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
import { TemplatesDataTable } from './data-table-templates';
import { EmptyTemplateState } from './empty-state';
import { NewTemplateDialog } from './new-template-dialog';
type TemplatesPageProps = {
searchParams?: {
page?: number;
perPage?: number;
};
};
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
const { user } = await getRequiredServerComponentSession();
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 10;
const { templates, totalPages } = await getTemplates({
userId: user.id,
page: page,
perPage: perPage,
});
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<div className="flex items-baseline justify-between">
<h1 className="mb-5 mt-2 truncate text-2xl font-semibold md:text-3xl">Templates</h1>
<div>
<NewTemplateDialog />
</div>
</div>
<div className="relative">
{templates.length > 0 ? (
<TemplatesDataTable
templates={templates}
page={page}
perPage={perPage}
totalPages={totalPages}
/>
) : (
<EmptyTemplateState />
)}
</div>
</div>
);
}

View File

@@ -2,7 +2,6 @@ import Link from 'next/link';
import { notFound } from 'next/navigation';
import { CheckCircle2, Clock8 } from 'lucide-react';
import { getServerSession } from 'next-auth';
import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
@@ -15,8 +14,6 @@ import { DocumentDownloadButton } from '@documenso/ui/components/document/docume
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { truncateTitle } from '~/helpers/truncate-title';
export type CompletedSigningPageProps = {
params: {
token?: string;
@@ -38,8 +35,6 @@ export default async function CompletedSigningPage({
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
const { documentData } = document;
const [fields, recipient] = await Promise.all([
@@ -58,9 +53,6 @@ export default async function CompletedSigningPage({
fields.find((field) => field.type === FieldType.NAME)?.customText ||
recipient.email;
const sessionData = await getServerSession();
const isLoggedIn = !!sessionData?.user;
return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
{/* Card with recipient */}
@@ -71,46 +63,34 @@ export default async function CompletedSigningPage({
/>
<div className="relative mt-6 flex w-full flex-col items-center">
{match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => (
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
<div className="text-documenso-700 flex items-center text-center">
<CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">Everyone has signed</span>
</div>
))
.with({ deletedAt: null }, () => (
.otherwise(() => (
<div className="flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span>
</div>
))
.otherwise(() => (
<div className="flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Document no longer available to sign</span>
</div>
))}
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
You have signed
<span className="mt-1.5 block">"{truncatedTitle}"</span>
<span className="mt-1.5 block">"{document.title}"</span>
</h2>
{match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => (
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
Everyone has signed! You will receive an Email copy of the signed document.
</p>
))
.with({ deletedAt: null }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
You will receive an Email copy of the signed document once everyone has signed.
</p>
))
.otherwise(() => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
This document has been cancelled by the owner and is no longer available for others to
sign.
You will receive an Email copy of the signed document once everyone has signed.
</p>
))}
@@ -125,21 +105,15 @@ export default async function CompletedSigningPage({
/>
</div>
{isLoggedIn ? (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
Go Back Home
<p className="text-muted-foreground/60 mt-36 text-sm">
Want to send slick signing links like this one?{' '}
<Link
href="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
</Link>
) : (
<p className="text-muted-foreground/60 mt-36 text-sm">
Want to send slick signing links like this one?{' '}
<Link
href="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
</Link>
</p>
)}
</p>
</div>
</div>
);

View File

@@ -6,13 +6,8 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -21,16 +16,9 @@ import { SigningFieldContainer } from './signing-field-container';
export type DateFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
dateFormat?: string | null;
timezone?: string | null;
};
export const DateField = ({
field,
recipient,
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
}: DateFieldProps) => {
export const DateField = ({ field, recipient }: DateFieldProps) => {
const router = useRouter();
const { toast } = useToast();
@@ -47,18 +35,12 @@ export const DateField = ({
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone);
const isDifferentTime = field.inserted && localDateString !== field.customText;
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
const onSign = async () => {
try {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
value: '',
});
startTransition(() => router.refresh());
@@ -93,13 +75,7 @@ export const DateField = ({
};
return (
<SigningFieldContainer
field={field}
onSign={onSign}
onRemove={onRemove}
type="Date"
tooltipText={isDifferentTime ? tooltipText : undefined}
>
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
@@ -111,7 +87,7 @@ export const DateField = ({
)}
{field.inserted && (
<p className="text-muted-foreground text-sm duration-200">{localDateString}</p>
<p className="text-muted-foreground text-sm duration-200">{field.customText}</p>
)}
</SigningFieldContainer>
);

View File

@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -79,7 +79,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
};
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />

View File

@@ -7,10 +7,9 @@ import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Document, Field, Recipient } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Document, Field, Recipient } from '@documenso/prisma/client';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -30,16 +29,11 @@ export type SigningFormProps = {
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession();
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation();
const {
handleSubmit,
formState: { isSubmitting },
@@ -49,11 +43,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
}, [fields]);
const fieldsValidated = () => {
setValidateUninsertedFields(true);
validateFieldsInserted(fields);
};
const onFormSubmit = async () => {
setValidateUninsertedFields(true);
@@ -68,12 +57,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
documentId: document.id,
});
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
router.push(`/sign/${recipient.token}/complete`);
};
@@ -98,11 +81,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
disabled={isSubmitting}
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
>
<div
className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
>
<div className={cn('flex flex-1 flex-col')}>
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
<p className="text-muted-foreground mt-2 text-sm">
@@ -159,7 +138,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
fieldsValidated={fieldsValidated}
/>
</div>
</div>

View File

@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
@@ -98,7 +98,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
};
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Name">
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />

View File

@@ -1,68 +0,0 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { Clock8 } from 'lucide-react';
import { useSession } from 'next-auth/react';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import type { Document, Signature } from '@documenso/prisma/client';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
type NoLongerAvailableProps = {
document: Document;
recipientName: string;
recipientSignature: Signature;
};
export const NoLongerAvailable = ({
document,
recipientName,
recipientSignature,
}: NoLongerAvailableProps) => {
const { data: session } = useSession();
return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
<SigningCard3D
name={recipientName}
signature={recipientSignature}
signingCelebrationImage={signingCelebration}
/>
<div className="relative mt-2 flex w-full flex-col items-center">
<div className="mt-8 flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Document Cancelled</span>
</div>
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<span className="mt-1.5 block">"{document.title}"</span>
is no longer available to sign
</h2>
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
This document has been cancelled by the owner.
</p>
{session?.user ? (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
Go Back Home
</Link>
) : (
<p className="text-muted-foreground/60 mt-36 text-sm">
Want to send slick signing links like this one?{' '}
<Link
href="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
</Link>
</p>
)}
</div>
</div>
);
};

View File

@@ -2,28 +2,21 @@ import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { truncateTitle } from '~/helpers/truncate-title';
import { DateField } from './date-field';
import { EmailField } from './email-field';
import { SigningForm } from './form';
import { NameField } from './name-field';
import { NoLongerAvailable } from './no-longer-available';
import { SigningProvider } from './provider';
import { SignatureField } from './signature-field';
@@ -47,14 +40,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
viewedDocument({ token }).catch(() => null),
]);
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
if (!document || !document.documentData || !recipient) {
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
const { documentData } = document;
const { user } = await getServerComponentSession();
@@ -66,18 +55,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
redirect(`/sign/${token}/complete`);
}
const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
if (document.deletedAt) {
return (
<NoLongerAvailable
document={document}
recipientName={recipient.name}
recipientSignature={recipientSignature}
/>
);
}
return (
<SigningProvider
email={recipient.email}
@@ -86,7 +63,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
>
<div className="mx-auto w-full max-w-screen-xl">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{truncatedTitle}
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
@@ -120,13 +97,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
<NameField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.DATE, () => (
<DateField
key={field.id}
field={field}
recipient={recipient}
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/>
<DateField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.EMAIL, () => (
<EmailField key={field.id} field={field} recipient={recipient} />

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import type { Document, Field } from '@documenso/prisma/client';
import { Document, Field } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -9,13 +9,10 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { truncateTitle } from '~/helpers/truncate-title';
export type SignDialogProps = {
isSubmitting: boolean;
document: Document;
fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>;
};
@@ -23,31 +20,30 @@ export const SignDialog = ({
isSubmitting,
document,
fields,
fieldsValidated,
onSignatureComplete,
}: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const truncatedTitle = truncateTitle(document.title);
const isComplete = fields.every((field) => field.inserted);
return (
<Dialog open={showDialog && isComplete} onOpenChange={setShowDialog}>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button
className="w-full"
type="button"
size="lg"
onClick={fieldsValidated}
disabled={!isComplete}
loading={isSubmitting}
>
{isComplete ? 'Complete' : 'Next field'}
Complete
</Button>
</DialogTrigger>
<DialogContent>
<div className="text-center">
<div className="text-xl font-semibold text-neutral-800">Sign Document</div>
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
You are about to finish signing "{truncatedTitle}". Are you sure?
You are about to finish signing "{document.title}". Are you sure?
</div>
</div>

View File

@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
@@ -76,16 +76,10 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
return;
}
const value = source === 'local' && localSignature ? localSignature : providedSignature ?? '';
if (!value) {
return;
}
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value,
value: source === 'local' && localSignature ? localSignature : providedSignature ?? '',
isBase64: true,
});
@@ -127,7 +121,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
};
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />

View File

@@ -2,9 +2,8 @@
import React from 'react';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type SignatureFieldProps = {
field: FieldWithSignature;
@@ -12,8 +11,6 @@ export type SignatureFieldProps = {
children: React.ReactNode;
onSign?: () => Promise<void> | void;
onRemove?: () => Promise<void> | void;
type?: 'Date' | 'Email' | 'Name' | 'Signature';
tooltipText?: string | null;
};
export const SigningFieldContainer = ({
@@ -22,8 +19,6 @@ export const SigningFieldContainer = ({
onSign,
onRemove,
children,
type,
tooltipText,
}: SignatureFieldProps) => {
const onSignFieldClick = async () => {
if (field.inserted) {
@@ -51,22 +46,7 @@ export const SigningFieldContainer = ({
/>
)}
{type === 'Date' && field.inserted && !loading && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
</TooltipTrigger>
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
</Tooltip>
)}
{type !== 'Date' && field.inserted && !loading && (
{field.inserted && !loading && (
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}

View File

@@ -13,14 +13,12 @@ export default function SignInPage() {
<SignInForm className="mt-4" />
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
)}
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
<p className="mt-2.5 text-center">
<Link

View File

@@ -1,13 +1,8 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { SignUpForm } from '~/components/forms/signup';
export default function SignUpPage() {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
redirect('/signin');
}
return (
<div>
<h1 className="text-4xl font-semibold">Create a new account</h1>

View File

@@ -1,97 +0,0 @@
import Link from 'next/link';
import { AlertTriangle, CheckCircle2, XCircle, XOctagon } from 'lucide-react';
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { Button } from '@documenso/ui/primitives/button';
export type PageProps = {
params: {
token: string;
};
};
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
if (!token) {
return (
<div className="w-full">
<div className="mb-4 text-red-300">
<XOctagon />
</div>
<h2 className="text-4xl font-semibold">No token provided</h2>
<p className="text-muted-foreground mt-2 text-base">
It seems that there is no token provided. Please check your email and try again.
</p>
</div>
);
}
const verified = await verifyEmail({ token });
if (verified === null) {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2>
<p className="text-muted-foreground mt-4">
We were unable to verify your email. If your email is not verified already, please try
again.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}
if (!verified) {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
<p className="text-muted-foreground mt-4">
It seems that the provided token has expired. We've just sent you another token, please
check your email and try again.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</h2>
<p className="text-muted-foreground mt-4">
Your email has been successfully confirmed! You can now use all features of Documenso.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}

View File

@@ -1,28 +0,0 @@
import Link from 'next/link';
import { XCircle } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export default function EmailVerificationWithoutTokenPage() {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Uh oh! Looks like you're missing a token</h2>
<p className="text-muted-foreground mt-4">
It seems that there is no token provided, if you are trying to verify your email please
follow the link in your email.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}

View File

@@ -12,6 +12,7 @@ import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import { ThemeProvider } from '~/providers/next-theme';
import { PlausibleProvider } from '~/providers/plausible';
import { PostHogPageview } from '~/providers/posthog';
import './globals.css';
@@ -68,11 +69,13 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<body>
<LocaleProvider locale={locale}>
<FeatureFlagProvider initialFlags={flags}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TrpcProvider>{children}</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
<PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TrpcProvider>{children}</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
</PlausibleProvider>
<Toaster />
</FeatureFlagProvider>

View File

@@ -1,46 +0,0 @@
'use client';
import React from 'react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = {
recipient: Recipient;
};
export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard();
const { toast } = useToast();
const onRecipientClick = () => {
void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The signing link has been copied to your clipboard.',
});
});
};
return (
<div className="my-1 flex cursor-pointer items-center gap-2" onClick={onRecipientClick}>
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<span
className="text-muted-foreground text-sm hover:underline"
title="Click to copy signing link for sending to recipient"
>
{recipient.email}
</span>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import { Recipient } from '@documenso/prisma/client';
import {
Tooltip,
TooltipContent,
@@ -8,7 +8,6 @@ import {
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { AvatarWithRecipient } from './avatar-with-recipient';
import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars';
@@ -69,7 +68,15 @@ export const StackAvatarsWithTooltip = ({
<div>
<h1 className="text-base font-medium">Waiting</h1>
{waitingRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<span className="text-muted-foreground text-sm">{recipient.email}</span>
</div>
))}
</div>
)}
@@ -78,7 +85,15 @@ export const StackAvatarsWithTooltip = ({
<div>
<h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<span className="text-muted-foreground text-sm">{recipient.email}</span>
</div>
))}
</div>
)}
@@ -87,7 +102,15 @@ export const StackAvatarsWithTooltip = ({
<div>
<h1 className="text-base font-medium">Uncompleted</h1>
{uncompletedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<span className="text-muted-foreground text-sm">{recipient.email}</span>
</div>
))}
</div>
)}

View File

@@ -4,16 +4,14 @@ import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
import { Monitor, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { useHotkeys } from 'react-hotkeys-hook';
import {
DOCUMENTS_PAGE_SHORTCUT,
SETTINGS_PAGE_SHORTCUT,
TEMPLATES_PAGE_SHORTCUT,
} from '@documenso/lib/constants/keyboard-shortcuts';
import { trpc as trpcReact } from '@documenso/trpc/react';
import {
CommandDialog,
CommandEmpty,
@@ -23,7 +21,6 @@ import {
CommandList,
CommandShortcut,
} from '@documenso/ui/primitives/command';
import { THEMES_TYPE } from '@documenso/ui/primitives/constants';
const DOCUMENTS_PAGES = [
{
@@ -32,28 +29,13 @@ const DOCUMENTS_PAGES = [
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
},
{ label: 'Draft documents', path: '/documents?status=DRAFT' },
{
label: 'Completed documents',
path: '/documents?status=COMPLETED',
},
{ label: 'Completed documents', path: '/documents?status=COMPLETED' },
{ label: 'Pending documents', path: '/documents?status=PENDING' },
{ label: 'Inbox documents', path: '/documents?status=INBOX' },
];
const TEMPLATES_PAGES = [
{
label: 'All templates',
path: '/templates',
shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''),
},
];
const SETTINGS_PAGES = [
{
label: 'Settings',
path: '/settings',
shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''),
},
{ label: 'Settings', path: '/settings', shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', '') },
{ label: 'Profile', path: '/settings/profile' },
{ label: 'Password', path: '/settings/password' },
];
@@ -71,32 +53,9 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const [search, setSearch] = useState('');
const [pages, setPages] = useState<string[]>([]);
const { data: searchDocumentsData, isLoading: isSearchingDocuments } =
trpcReact.document.searchDocuments.useQuery(
{
query: search,
},
{
keepPreviousData: true,
},
);
const searchResults = useMemo(() => {
if (!searchDocumentsData) {
return [];
}
return searchDocumentsData.map((document) => ({
label: document.title,
path: `/documents/${document.id}`,
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
}));
}, [searchDocumentsData]);
const currentPage = pages[pages.length - 1];
const toggleOpen = (e: KeyboardEvent) => {
e.preventDefault();
const toggleOpen = () => {
setIsOpen((isOpen) => !isOpen);
onOpenChange?.(!isOpen);
@@ -134,12 +93,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]);
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen);
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates);
const handleKeyDown = (e: React.KeyboardEvent) => {
// Escape goes to previous page
@@ -156,13 +113,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
};
return (
<CommandDialog
commandProps={{
onKeyDown: handleKeyDown,
}}
open={open}
onOpenChange={setOpen}
>
<CommandDialog commandProps={{ onKeyDown: handleKeyDown }} open={open} onOpenChange={setOpen}>
<CommandInput
value={search}
onValueChange={setSearch}
@@ -170,36 +121,18 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
/>
<CommandList>
{isSearchingDocuments ? (
<CommandEmpty>
<div className="flex items-center justify-center">
<span className="animate-spin">
<Loader />
</span>
</div>
</CommandEmpty>
) : (
<CommandEmpty>No results found.</CommandEmpty>
)}
<CommandEmpty>No results found.</CommandEmpty>
{!currentPage && (
<>
<CommandGroup heading="Documents">
<Commands push={push} pages={DOCUMENTS_PAGES} />
</CommandGroup>
<CommandGroup heading="Templates">
<Commands push={push} pages={TEMPLATES_PAGES} />
</CommandGroup>
<CommandGroup heading="Settings">
<Commands push={push} pages={SETTINGS_PAGES} />
</CommandGroup>
<CommandGroup heading="Preferences">
<CommandItem onSelect={() => addPage('theme')}>Change theme</CommandItem>
</CommandGroup>
{searchResults.length > 0 && (
<CommandGroup heading="Your documents">
<Commands push={push} pages={searchResults} />
</CommandGroup>
)}
</>
)}
{currentPage === 'theme' && <ThemeCommands setTheme={setTheme} />}
@@ -213,14 +146,10 @@ const Commands = ({
pages,
}: {
push: (_path: string) => void;
pages: { label: string; path: string; shortcut?: string; value?: string }[];
pages: { label: string; path: string; shortcut?: string }[];
}) => {
return pages.map((page, idx) => (
<CommandItem
key={page.path + idx}
value={page.value ?? page.label}
onSelect={() => push(page.path)}
>
return pages.map((page) => (
<CommandItem key={page.path} onSelect={() => push(page.path)}>
{page.label}
{page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>}
</CommandItem>
@@ -230,9 +159,9 @@ const Commands = ({
const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => {
const THEMES = useMemo(
() => [
{ label: 'Light Mode', theme: THEMES_TYPE.LIGHT, icon: Sun },
{ label: 'Dark Mode', theme: THEMES_TYPE.DARK, icon: Moon },
{ label: 'System Theme', theme: THEMES_TYPE.SYSTEM, icon: Monitor },
{ label: 'Light Mode', theme: 'light', icon: Sun },
{ label: 'Dark Mode', theme: 'dark', icon: Moon },
{ label: 'System Theme', theme: 'system', icon: Monitor },
],
[],
);

View File

@@ -1,10 +1,6 @@
'use client';
import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { HTMLAttributes, useState } from 'react';
import { Search } from 'lucide-react';
@@ -13,57 +9,17 @@ import { Button } from '@documenso/ui/primitives/button';
import { CommandMenu } from '../common/command-menu';
const navigationLinks = [
{
href: '/documents',
label: 'Documents',
},
{
href: '/templates',
label: 'Templates',
},
];
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const pathname = usePathname();
// const pathname = usePathname();
const [open, setOpen] = useState(false);
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
useEffect(() => {
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent);
setModifierKey(isMacOS ? '⌘' : 'Ctrl');
}, []);
return (
<div
className={cn(
'ml-8 hidden flex-1 items-center gap-x-12 md:flex md:justify-between',
className,
)}
className={cn('ml-8 hidden flex-1 gap-x-6 md:flex md:justify-center', className)}
{...props}
>
<div className="flex items-baseline gap-x-6">
{navigationLinks.map(({ href, label }) => (
<Link
key={href}
href={href}
className={cn(
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
{
'text-foreground dark:text-muted-foreground': pathname?.startsWith(href),
},
)}
>
{label}
</Link>
))}
</div>
<CommandMenu open={open} onOpenChange={setOpen} />
<Button
@@ -77,11 +33,24 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</div>
<div>
<div className="text-muted-foreground bg-muted flex items-center rounded-md px-1.5 py-0.5 text-xs tracking-wider">
{modifierKey}+K
<div className="text-muted-foreground bg-muted rounded-md px-1.5 py-0.5 font-mono text-xs">
Ctrl+K
</div>
</div>
</Button>
{/* We have no other subpaths rn */}
{/* <Link
href="/documents"
className={cn(
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
{
'text-foreground': pathname?.startsWith('/documents'),
},
)}
>
Documents
</Link> */}
</div>
);
};

View File

@@ -1,11 +1,10 @@
'use client';
import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import { HTMLAttributes, useEffect, useState } from 'react';
import Link from 'next/link';
import type { User } from '@documenso/prisma/client';
import { User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Logo } from '~/components/branding/logo';
@@ -33,7 +32,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
return (
<header
className={cn(
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[50] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
scrollY > 5 && 'border-b-border',
className,
)}
@@ -49,7 +48,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
<DesktopNav />
<div className="flex gap-x-4 md:ml-8">
<div className="flex gap-x-4">
<ProfileDropdown user={user} />
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">

View File

@@ -4,8 +4,7 @@ import Link from 'next/link';
import {
CreditCard,
FileSpreadsheet,
Lock,
Key,
LogOut,
User as LucideUser,
Monitor,
@@ -21,7 +20,7 @@ import { LuGithub } from 'react-icons/lu';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import type { User } from '@documenso/prisma/client';
import { User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -57,11 +56,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
title="Profile Dropdown"
className="relative h-10 w-10 rounded-full"
>
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar className="h-10 w-10">
<AvatarFallback>{avatarFallback}</AvatarFallback>
</Avatar>
@@ -92,9 +87,9 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings/security" className="cursor-pointer">
<Lock className="mr-2 h-4 w-4" />
Security
<Link href="/settings/password" className="cursor-pointer">
<Key className="mr-2 h-4 w-4" />
Password
</Link>
</DropdownMenuItem>
@@ -107,13 +102,6 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/templates" className="cursor-pointer">
<FileSpreadsheet className="mr-2 h-4 w-4" />
Templates
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>

View File

@@ -1,123 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { ONE_SECOND } from '@documenso/lib/constants/time';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type VerifyEmailBannerProps = {
email: string;
};
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
const { mutateAsync: sendConfirmationEmail, isLoading } =
trpc.profile.sendConfirmationEmail.useMutation();
const onResendConfirmationEmail = async () => {
try {
setIsButtonDisabled(true);
await sendConfirmationEmail({ email: email });
toast({
title: 'Success',
description: 'Verification email sent successfully.',
});
setIsOpen(false);
setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT);
} catch (err) {
setIsButtonDisabled(false);
toast({
title: 'Error',
description: 'Something went wrong while sending the confirmation email.',
variant: 'destructive',
});
}
};
useEffect(() => {
// Check localStorage to see if we've recently automatically displayed the dialog
// if it was within the past 24 hours, don't show it again
// otherwise, show it again and update the localStorage timestamp
const emailVerificationDialogLastShown = localStorage.getItem(
'emailVerificationDialogLastShown',
);
if (emailVerificationDialogLastShown) {
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) {
return;
}
}
setIsOpen(true);
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
}, []);
return (
<>
<div className="bg-yellow-200 dark:bg-yellow-400">
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium text-yellow-900">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
Verify your email address to unlock all features.
</div>
<div>
<Button
variant="ghost"
className="h-auto px-2.5 py-1.5 text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500"
disabled={isButtonDisabled}
onClick={() => setIsOpen(true)}
size="sm"
>
{isButtonDisabled ? 'Verification Email Sent' : 'Verify Now'}
</Button>
</div>
</div>
</div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogTitle>Verify your email address</DialogTitle>
<DialogDescription>
We've sent a confirmation email to <strong>{email}</strong>. Please check your inbox and
click the link in the email to verify your account.
</DialogDescription>
<div>
<Button
disabled={isButtonDisabled}
loading={isLoading}
onClick={onResendConfirmationEmail}
>
{isLoading ? 'Sending...' : 'Resend Confirmation Email'}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CreditCard, Lock, User } from 'lucide-react';
import { CreditCard, Key, User } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -35,16 +35,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
<Link href="/settings/security">
<Link href="/settings/password">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/security') && 'bg-secondary',
pathname?.startsWith('/settings/password') && 'bg-secondary',
)}
>
<Lock className="mr-2 h-5 w-5" />
Security
<Key className="mr-2 h-5 w-5" />
Password
</Button>
</Link>

View File

@@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CreditCard, Lock, User } from 'lucide-react';
import { CreditCard, Key, User } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -38,16 +38,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
<Link href="/settings/security">
<Link href="/settings/password">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/security') && 'bg-secondary',
pathname?.startsWith('/settings/password') && 'bg-secondary',
)}
>
<Lock className="mr-2 h-5 w-5" />
Security
<Key className="mr-2 h-5 w-5" />
Password
</Button>
</Link>

View File

@@ -1,29 +0,0 @@
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { LocaleDate } from '~/components/formatter/locale-date';
export type CommentCardProps = {
comment: any;
className?: string;
};
export const CommentCard = ({ comment, className }: CommentCardProps) => {
return (
<div className={cn('mb-8', className)} key={comment.id}>
<p className="font-semibold">{comment.User.name}</p>
<p className="text-sm">
<LocaleDate
date={comment.createdAt}
format={{
month: 'long',
day: 'numeric',
year: 'numeric',
}}
/>
</p>
<p className="mb-2 mt-2 text-base">{comment.comment}</p>
<Button>Reply</Button>
</div>
);
};

View File

@@ -1,9 +1,9 @@
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import { CheckCircle2, Clock, File } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils';

View File

@@ -1,10 +1,8 @@
'use client';
import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import { HTMLAttributes, useEffect, useState } from 'react';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import { DateTime, DateTimeFormatOptions } from 'luxon';
import { useLocale } from '@documenso/lib/client-only/providers/locale';

View File

@@ -1,50 +0,0 @@
import { HTMLAttributes } from 'react';
import { Globe, Lock } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
type TemplateTypeIcon = {
label: string;
icon?: LucideIcon;
color: string;
};
type TemplateTypes = (typeof TemplateTypePrisma)[keyof typeof TemplateTypePrisma];
const TEMPLATE_TYPES: Record<TemplateTypes, TemplateTypeIcon> = {
PRIVATE: {
label: 'Private',
icon: Lock,
color: 'text-blue-600 dark:text-blue-300',
},
PUBLIC: {
label: 'Public',
icon: Globe,
color: 'text-green-500 dark:text-green-300',
},
};
export type TemplateTypeProps = HTMLAttributes<HTMLSpanElement> & {
type: TemplateTypes;
inheritColor?: boolean;
};
export const TemplateType = ({ className, type, inheritColor, ...props }: TemplateTypeProps) => {
const { label, icon: Icon, color } = TEMPLATE_TYPES[type];
return (
<span className={cn('flex items-center', className)} {...props}>
{Icon && (
<Icon
className={cn('mr-2 inline-block h-4 w-4', {
[color]: !inheritColor,
})}
/>
)}
{label}
</span>
);
};

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