Compare commits

..

4 Commits

Author SHA1 Message Date
f9b2abcadd extend webhook triggers 2024-02-24 10:54:20 +02:00
99a26065a8 zapier webhooks 2024-02-23 15:02:53 +02:00
91375a17c2 chore: merged webhooks 2024-02-22 09:54:43 +02:00
a0aeca48f2 chore: merged api 2024-02-21 13:44:08 +02:00
625 changed files with 64194 additions and 37120 deletions

View File

@ -10,13 +10,7 @@
"ghcr.io/devcontainers/features/node:1": {}
},
"onCreateCommand": "./.devcontainer/on-create.sh",
"forwardPorts": [
3000,
54320,
9000,
2500,
1100
],
"forwardPorts": [3000, 54320, 9000, 2500, 1100],
"customizations": {
"vscode": {
"extensions": [
@ -31,8 +25,8 @@
"GitHub.copilot",
"GitHub.vscode-pull-request-github",
"Prisma.prisma",
"VisualStudioExptTeam.vscodeintellicode"
"VisualStudioExptTeam.vscodeintellicode",
]
}
}
}
}

View File

@ -1,10 +1,13 @@
#!/usr/bin/env bash
# Start the database and mailserver
docker compose -f ./docker/compose-without-app.yml up -d
# Install dependencies
npm install
# Copy the env file
cp .env.example .env
# Run the dev setup
npm run dx
# Run the migrations
npm run prisma:migrate-dev

View File

@ -13,10 +13,6 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
NEXT_PRIVATE_OIDC_CLIENT_ID=""
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
# [[URLS]]
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
@ -26,32 +22,16 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# [[SIGNING]]
# The transport to use for document signing. Available options: local (default) | gcloud-hsm
NEXT_PRIVATE_SIGNING_TRANSPORT="local"
# OPTIONAL: The passphrase to use for the local file-based signing transport.
NEXT_PRIVATE_SIGNING_PASSPHRASE=
# OPTIONAL: The local file path to the .p12 file to use for the local signing transport.
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=
# OPTIONAL: The base64-encoded contents of the .p12 file to use for the local signing transport.
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS=
# OPTIONAL: The path to the Google Cloud HSM key to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH=
# OPTIONAL: The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH=
# OPTIONAL: The base64-encoded contents of the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS=
# OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS=
# [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
# OPTIONAL: Defines the force path style to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
# This will change it from using virtual hosts <bucket>.domain.com/<path> to fully qualified paths domain.com/<bucket>/<path>
NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE="false"
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
NEXT_PRIVATE_UPLOAD_REGION="unknown"
# REQUIRED: Defines the bucket to use for the S3 storage transport.
@ -79,7 +59,7 @@ NEXT_PRIVATE_SMTP_APIKEY=
# OPTIONAL: Defines whether to force the use of TLS.
NEXT_PRIVATE_SMTP_SECURE=
# REQUIRED: Defines the sender name to use for the from address.
NEXT_PRIVATE_SMTP_FROM_NAME="Documenso"
NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso"
# REQUIRED: Defines the email address to use as the from address.
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
# OPTIONAL: The API key to use for Resend.com
@ -101,7 +81,6 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID=
# [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags.
@ -111,11 +90,6 @@ NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Leave blank to allow users to signup through /signup page.
NEXT_PUBLIC_DISABLE_SIGNUP=
# [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# This is only required for the marketing site
# [[REDIS]]
NEXT_PRIVATE_REDIS_URL=

View File

@ -1,24 +0,0 @@
name: Cache production build binaries
description: 'Cache or restore if necessary'
inputs:
node_version:
required: false
default: v18.x
runs:
using: 'composite'
steps:
- name: Cache production build
uses: actions/cache@v3
id: production-build-cache
with:
path: |
${{ github.workspace }}/apps/web/.next
${{ github.workspace }}/apps/marketing/.next
**/.turbo/**
**/dist/**
key: prod-build-${{ github.run_id }}
restore-keys: prod-build-
- run: npm run build
shell: bash

View File

@ -1,39 +0,0 @@
name: 'Setup node and cache node_modules'
inputs:
node_version:
required: false
default: v18.x
runs:
using: 'composite'
steps:
- name: Set up Node ${{ inputs.node_version }}
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Cache npm
uses: actions/cache@v3
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
restore-keys: npm-
- name: Cache node_modules
uses: actions/cache@v3
id: cache-node-modules
with:
path: |
node_modules
packages/*/node_modules
apps/*/node_modules
key: modules-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
shell: bash
run: |
npm ci --no-audit
npm run prisma:generate
env:
HUSKY: '0'

View File

@ -1,19 +0,0 @@
name: Install playwright binaries
description: 'Install playwright, cache and restore if necessary'
runs:
using: 'composite'
steps:
- name: Cache playwright
id: cache-playwright
uses: actions/cache@v3
with:
path: |
~/.cache/ms-playwright
${{ github.workspace }}/node_modules/playwright
key: playwright-${{ hashFiles('**/package-lock.json') }}
restore-keys: playwright-
- name: Install playwright
if: steps.cache-playwright.outputs.cache-hit != 'true'
run: npx playwright install --with-deps
shell: bash

View File

@ -1,7 +1,6 @@
name: 'Continuous Integration'
on:
workflow_call:
push:
branches: ['main']
pull_request:
@ -11,6 +10,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
env:
HUSKY: 0
jobs:
build_app:
name: Build App
@ -21,12 +23,20 @@ jobs:
with:
fetch-depth: 2
- uses: ./.github/actions/node-install
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
- name: Copy env
run: cp .env.example .env
- uses: ./.github/actions/cache-build
- name: Build
run: npm run build
build_docker:
name: Build Docker Image
@ -37,31 +47,5 @@ jobs:
with:
fetch-depth: 2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Build Docker Image
uses: docker/build-push-action@v5
with:
push: false
context: .
file: ./docker/Dockerfile
tags: documenso-${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- # Temp fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
run: ./docker/build.sh

View File

@ -1,29 +0,0 @@
name: cleanup caches by a branch
on:
pull_request:
types:
- closed
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Cleanup
run: |
gh extension install actions/gh-actions-cache
echo "Fetching list of cache key"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 )
## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
do
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
done
echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge

View File

@ -25,17 +25,24 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: Install Dependencies
run: npm ci
- name: Copy env
run: cp .env.example .env
- uses: ./.github/actions/node-install
- uses: ./.github/actions/cache-build
- name: Build Documenso
run: npm run build
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v2

View File

@ -6,21 +6,29 @@ on:
branches: ['main']
jobs:
e2e_tests:
name: 'E2E Tests'
name: "E2E Tests"
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
- name: Copy env
run: cp .env.example .env
- uses: ./.github/actions/node-install
- name: Start Services
run: npm run dx:up
- uses: ./.github/actions/playwright-install
- 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
@ -28,16 +36,14 @@ jobs:
- name: Seed the database
run: npm run prisma:seed
- uses: ./.github/actions/cache-build
- name: Run Playwright tests
run: npm run ci
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v3
if: always()
with:
name: test-results
path: 'packages/app-tests/**/test-results/*'
path: "packages/app-tests/**/test-results/*"
retention-days: 30
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}

View File

@ -27,7 +27,7 @@ jobs:
- name: Check Assigned User's Issue Count
id: parse-comment
uses: actions/github-script@v6
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View File

@ -1,25 +0,0 @@
name: Auto Label Assigned Issues
on:
issues:
types: [assigned]
jobs:
label-when-assigned:
runs-on: ubuntu-latest
steps:
- name: Label issue
uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const issue = context.issue;
// To run only on issues and not on PR
if (github.context.payload.issue.pull_request === undefined) {
const labelResponse = await github.rest.issues.addLabels({
owner: issue.owner,
repo: issue.repo,
issue_number: issue.number,
labels: ['status: assigned']
});
}

View File

@ -17,5 +17,5 @@ jobs:
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ["status: triage"]
labels: ["needs triage"]
})

View File

@ -2,14 +2,14 @@ name: 'PR Review Reminder'
on:
pull_request:
types: ['opened', 'ready_for_review']
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' || 'ready_for_review')
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested')
runs-on: ubuntu-latest
steps:
- name: Checkout

View File

@ -1,133 +0,0 @@
name: Publish Docker
on:
push:
branches: ['release']
jobs:
build_and_publish_platform_containers:
name: Build and publish platform containers
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- warp-ubuntu-latest-x64-4x
- warp-ubuntu-latest-arm64-4x
steps:
- uses: actions/checkout@v4
with:
fetch-tags: true
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GH_TOKEN }}
- name: Build the docker image
env:
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
docker build \
-f ./docker/Dockerfile \
--progress=plain \
-t "documenso/documenso-$BUILD_PLATFORM:latest" \
-t "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA" \
-t "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION" \
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:latest" \
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$GIT_SHA" \
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$APP_VERSION" \
.
- name: Push the docker image to DockerHub
run: docker push --all-tags "documenso/documenso-$BUILD_PLATFORM"
env:
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
- name: Push the docker image to GitHub Container Registry
run: docker push --all-tags "ghcr.io/documenso/documenso-$BUILD_PLATFORM"
env:
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
create_and_publish_manifest:
name: Create and publish manifest
runs-on: ubuntu-latest
needs: build_and_publish_platform_containers
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-tags: true
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GH_TOKEN }}
- name: Create and push DockerHub manifest
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
docker manifest create \
documenso/documenso:latest \
--amend documenso/documenso-amd64:latest \
--amend documenso/documenso-arm64:latest \
docker manifest create \
documenso/documenso:$GIT_SHA \
--amend documenso/documenso-amd64:$GIT_SHA \
--amend documenso/documenso-arm64:$GIT_SHA \
docker manifest create \
documenso/documenso:$APP_VERSION \
--amend documenso/documenso-amd64:$APP_VERSION \
--amend documenso/documenso-arm64:$APP_VERSION \
docker manifest push documenso/documenso:latest
docker manifest push documenso/documenso:$GIT_SHA
docker manifest push documenso/documenso:$APP_VERSION
- name: Create and push Github Container Registry manifest
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
docker manifest create \
ghcr.io/documenso/documenso:latest \
--amend ghcr.io/documenso/documenso-amd64:latest \
--amend ghcr.io/documenso/documenso-arm64:latest \
docker manifest create \
ghcr.io/documenso/documenso:$GIT_SHA \
--amend ghcr.io/documenso/documenso-amd64:$GIT_SHA \
--amend ghcr.io/documenso/documenso-arm64:$GIT_SHA \
docker manifest create \
ghcr.io/documenso/documenso:$APP_VERSION \
--amend ghcr.io/documenso/documenso-amd64:$APP_VERSION \
--amend ghcr.io/documenso/documenso-arm64:$APP_VERSION \
docker manifest push ghcr.io/documenso/documenso:latest
docker manifest push ghcr.io/documenso/documenso:$GIT_SHA
docker manifest push ghcr.io/documenso/documenso:$APP_VERSION

View File

@ -12,7 +12,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@v5
- uses: actions/stale@v4
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-pr-stale: 90

View File

@ -6,7 +6,7 @@ tasks:
set -a; source .env &&
export NEXTAUTH_URL="$(gp url 3000)" &&
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
command: npm run d
ports:
@ -25,10 +25,20 @@ ports:
- port: 2500
visibility: private
onOpen: ignore
- port: 54320
visibility: private
- port: 54320
visibility: private
onOpen: ignore
github:
prebuilds:
master: true
pullRequests: true
pullRequestsFromForks: true
addCheck: true
addComment: true
addBadge: true
vscode:
extensions:
- aaron-bond.better-comments
@ -37,5 +47,9 @@ vscode:
- esbenp.prettier-vscode
- mikestead.dotenv
- unifiedjs.vscode-mdx
- GitHub.copilot-chat
- GitHub.copilot-labs
- GitHub.copilot
- GitHub.vscode-pull-request-github
- Prisma.prisma
- VisualStudioExptTeam.vscodeintellicode

View File

@ -1,9 +1,7 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
"javascript.preferences.importModuleSpecifier": "non-relative",

View File

@ -1,3 +1,5 @@
>We are nominated for a Product Hunt Gold Kitty 😺✨ and appreciate any support: https://documen.so/kitty
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
<p align="center" style="margin-top: 20px">
@ -27,11 +29,20 @@
<a href="https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/documenso/documenso">
<img alt="open in devcontainer" src="https://img.shields.io/static/v1?label=Dev%20Containers&message=Enabled&color=blue&logo=visualstudiocode" />
</a>
<a href="CODE_OF_CONDUCT.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg" alt="Contributor Covenant"></a>
<a href="code_of_conduct.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg" alt="Contributor Covenant"></a>
</p>
<div align="center">
<img src="https://github.com/documenso/documenso/assets/13398220/d96ed533-6f34-4a97-be9b-442bdb189c69" style="width: 80%;" />
<div>
<img style="display: block; height: 120px; width: 24%"
src="https://github.com/documenso/documenso/assets/1309312/67e08c98-c153-4115-aa2d-77979bb12c94)">
<img style="display: block; height: 120px; width: 24%"
src="https://github.com/documenso/documenso/assets/1309312/040cfbae-3438-4ca3-87f2-ce52c793dcaf">
<img style="display: block; height: 120px; width: 24%"
src="https://github.com/documenso/documenso/assets/1309312/72d445be-41e5-4936-bdba-87ef8e70fa09">
<img style="display: block; height: 120px; width: 24%"
src="https://github.com/documenso/documenso/assets/1309312/d7b86c0f-a755-4476-a022-a608db2c4633">
<img style="display: block; height: 120px; width: 24%"
src=https://github.com/documenso/documenso/assets/1309312/c0f55116-ab82-433f-a266-f3fc8571d69f">
</div>
## About this project
@ -82,7 +93,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
- [NextAuth.js](https://next-auth.js.org/) - Authentication
- [react-email](https://react.email/) - Email Templates
- [tRPC](https://trpc.io/) - API
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures
- [Node SignPDF](https://github.com/vbuch/node-signpdf) - Digital Signature
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
- [Stripe](https://stripe.com/) - Payments
@ -96,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
To run Documenso locally, you will need
- Node.js (v18 or above)
- Node.js
- Postgres SQL Database
- Docker (optional)
@ -198,16 +209,7 @@ If you're a visual learner and prefer to watch a video walkthrough of setting up
## Docker
We provide a Docker container for Documenso, which is published on both DockerHub and GitHub Container Registry.
- DockerHub: [https://hub.docker.com/r/documenso/documenso](https://hub.docker.com/r/documenso/documenso)
- GitHub Container Registry: [https://ghcr.io/documenso/documenso](https://ghcr.io/documenso/documenso)
You can pull the Docker image from either of these registries and run it with your preferred container hosting provider.
Please note that you will need to provide environment variables for connecting to the database, mailserver, and so forth.
For detailed instructions on how to configure and run the Docker container, please refer to the [Docker README](./docker/README.md) in the `docker` directory.
🚧 Docker containers and images are current in progress. We are actively working on bringing a simple Docker build and publish pipeline for Documenso.
## Self Hosting

View File

@ -17,8 +17,7 @@ For the digital signature of your documents you need a signing certificate in .p
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created)
5. Place the certificate `/apps/web/resources/certificate.p12`
## Docker

View File

@ -1,6 +1,6 @@
---
title: 'Building Documenso — Part 1: Certificates'
description: Let's take a look why you need a signing certificate and how Documenso does it.
description: In today's fast-paced world, productivity and efficiency are crucial for success, both in personal and professional endeavors. We all strive to make the most of our time and energy to achieve our goals effectively. However, it's not always easy to stay on track and maintain peak performance. In this blog post, we'll explore 10 valuable tips to help you boost productivity and efficiency in your daily life.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'

View File

@ -1,117 +0,0 @@
---
title: 'Building Documenso — Part 2: Signature Validity'
description: Is a signature valid? And what does that mean? It's a surprisingly complex question; let's take a look.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-04-05
tags:
- Document Signature
- Certificates
- Signing
---
<figure>
<MdxNextImage
src="/blog/eu-validate-1.png"
width= "650"
height= "650"
alt= "A report card for signature validity."
/>
<figcaption className="text-center">
If a tree does not comply with the EU trust list, does it make a sound when validating?r
</figcaption>
</figure>
> TLDR; Signatures can be valid and compliant for different signature levels, even if some validators show higher-level errors. Not all helpful security measures are mandated by law.
# A valid question
A few days ago, an early adopter brought up this question in our [Discord](https://documen.so/discord):
<figure>
<MdxNextImage
src="/blog/eu-validate-2.png"
width= "650"
height= "650"
alt= "A report card for signature validity."
/>
<figcaption className="text-center">
You can check out the validator here: [https://documen.so/eu-validator](https://documen.so/eu-validator)
</figcaption>
</figure>
For those unfamiliar with the tool, he used the validator tool of the EU's Digital Signature Service (DSS) Framework to check the signature of a document signed with Documenso. The EU provides this tool to help users and providers check the validity level of their signatures.
A short refresher from [Building Documenso — Part 1: Certificates](https://documen.so/certs):
> Documenso inserts all visual signatures into the document and then seals it using the "Documenso Inc." corporate certificate. This makes the resulting PDF document tamper-proof and guarantees it hasn't changed since signing.
Before we answer if the document was signed correctly, we need to understand what the goal was.
There are three signature levels in the European eIDAS regulation:
1. **Simple Electronic Signatures (Level 1/ SES):** This is just a visual signature or even a checkbox on a document.
2. **Advanded Electronic Signatures (Level 2/ AES)**: An actual crypographic signature (not just a seal on the whole document, but a specific signature), using a certificate linked to the identification data of the signer.
3. **Qualified Electronic Signatures (Level 3/ QES):** Same as 2. but done by a government-certified entity on certified hardware and after identifying the signer with an official ID document (e.g., passport)
> 💡 Side Note: Number 2 (AES) is how most people imagine digital signatures. But most of the market uses 1. plus a seal on the whole document under the name of the signing provider (e.g., Documenso). The signer's data is only inserted visually, not in the actual signature. Why? One of the reasons is that it's much easier, and without a readily available open source framework to draw from, it is quite tricky to build. This is something we aim to build (which many have done) and open source (which no one has done).
From the perspective of eIDAS, Documenso offers Level 1/ SES signatures since it does not adhere to all of the requirements of Level 2/ AES. This means that, technically, there is no legal need to seal the document to achieve this level of validity (at least within eIDAS). We do it anyway since it improves the level of confidence users can have in the signed document. Sealing the document, even though not legally required, is a great example of Documenso's approach to signatures. First, we aim to provide all legal requirements for a given use case. Then, we add any protection that can be added without unwarranted friction to the creation of the signature.
## Not if valid, but how valid
**Q: So, is the signature in the image valid?**
A: Yes, as an eidas Level 1 SES.
**Q: Then why does it say "Unable to build a certificate chain up to a trusted list"**
A: The certificate we use to seal the document after inserting the signatures is not on the EU Trust list.
**Q: Does that mean it is less secure?**
A: No, it means the provider (Wisekey) is not on a list maintained by the EU. The cryptographic signature is just as strong as any other
For someone who does not deal with this stuff daily, this can be hard to comprehend. Whether you use a certificate you generated yourself, one generated by a certificate authority (CA) like Wisekey, or one by another on the EU trust list (e.g., Bundesdruckerei), the cryptographic security guaranteeing that the document has not been tampered with is always the same. Many providers like Documenso, DocuSign, PandaDoc, and Digisigner all use this method for their regular plans. That means if you were to run a document signed by them through the validator above, the result would be the same[1]. The interesting question is why? Why do it like this?
## Certificate Infrastructure is broken
While there are some actual expenses involved in providing AES and QES, the blunt reality is that it's just good business to charge for them per signature, making it unsuitable for the "standard offerings"; almost no one has the resources to set this up themselves. While this initial process of becoming a QES-certified entity is really expensive, selling the certificates afterward is very lucrative. This leads to less innovation in the space and only big players providing these high-compliance services. Even certificates only used to seal documents without being QES certified are sold for a large range of prices, and they cost almost nothing to produce.
## Why Though?
**Q: Why do people buy a certificate for money and not just generate one themselves? Isn't the cryptographic security the same?**
A: Self-generated certificates are not recognized for higher-level compliance signatures like QES
**Q: So if you don't need higher-level signatures, you could just generate one yourself?**
A: Yes, you could. Since eIDAS Level 1 does not require a cert, you could use your own.
**Q: Why don't more people?**
A: One reason is that apart from the EU trust list, there are others, like the Adobe trust list. While not legally required, being on that one (like Wisekey) gives you a green checkmark in Adobe PDF, which is how most people check signature validity.
**Q: Not a question, but all of this sounds weird**
A: It is. This is one of the reasons why Documenso exists. We plan to make this easier.
**Q: How?**
A: By explaining and providing easy-to-use tools and eventually free, highly compliant signature certificates for everyone.
Eventually, we plan to start a free certificate authority called Let's Sign, named after another instituion that broke the paid certificate paradigm to the benefit of the internet: [Let's Encrypt](https://letsencrypt.org/).
As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments.
Best from Hamburg\
Timur
\
\
\
[1] The signature format (e.g. PKCS7-B) will vary. It's the format what the signature inserted into the document looks like. eIDAS itself does not specifically require any given format, but the PAdES defined by the EU is mostly used by european providers.

View File

@ -24,8 +24,6 @@ tags:
</figcaption>
</figure>
> 🔔 UPDATE: We launched <a href="https://documen.so/day1" target="_blank">teams</a> and the early adopters plan will be replaced by the new teams pricing as soon as all availible early adopters seats are filled.
## Community-Driven Development
As we ramp up hiring and development speed for Documenso, I want to discuss how we plan to build its core version.

View File

@ -1,64 +0,0 @@
---
title: Launch Week II - Day 1 - Teams
description: Teams for Documenso are here. And they come free for early adopters!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-26
tags:
- Launch Week
- Teams
- Early Adopter Perks
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/12a85ec7-20bb-4813-9714-e4da42c9cfba"
autoPlay
loop
muted
></video>
> TLDR; Docucmenso now supports teams that share documents, templates and a team mail address. Early Adopter get UNLIMITED<sup>1</sup> Users.
## Kicking off Launch Week II - "Connected"
The day has come! Roughly 5 months after kicked off our first launch week with open sourcing our design and Malfunction Mania, Launch Week #2 is here 🎉 This Launch Week's theme is "connected", since this is all about connecting humans, machines and documents.
Working with documents and getting that signature is a team sport. This is why we are kicking it off today with a very long-awaited feature: Documenso now supports teams!
## Introducing Teams for Documenso
You can now create teams next to your personal account: Simply invite your colleagues, and you can include everyone you like in working with your documents. With teams, you can:
- Send unlimited signature requests with unlimited recipients
- Create, view, edit and sign documents owned by the team
- Define a dedicated team email, to receive signing requests into a team inbox for the owner to sign
- Manage team roles: Member (Create+Edit), Managers (+Manage Team Members), Owner (+Transfer Team +Delete Team + Sign Documents sent to team email)
## Pricing
Together with Teams, we are announcing the new teams pricing:
- $10 per seat per month
- 5 seats minimum
- You can add seats dynamically as needed
This pricing will take effect, as soon as the early adopter seats run out. Want to check out teams: [https://documen.so/teams](https://documen.so/teams).
## Early Adopter Perks
There is one more point on pricing I have been looking forward to for a long time:
All early adopter plans now include **UNLIMITED teams and users**<sup>1</sup> . We appreciate your support so far very much, and I'm happy to announce this first of more early adopter perks to come. We have roughly 48 early adopter plans left, so if you plan to onboard your team, now is a great time to [grab your early adopter seat.](https://documen.so/claim-early-adopters-plan)
We are eager to hear from all teams users how you like this addition and what we can add to make it even better. Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here, and we would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 1! Teams just dropped. Check it out https://documen.so/day1 🚀"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur
\
[1] Within reason. If you are unsure what that means, feel free to contact hi@documenso.com and ask for clarification if it's more than 100.

View File

@ -1,76 +0,0 @@
---
title: Launch Week II - Day 2 - Templates
description: Templates help you prepare regular documents faster. And you can share them with your team!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-27
tags:
- Launch Week
- Templates
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/c9504db1-26b7-4033-88ed-a95cabd02e92"
autoPlay
loop
muted
></video>
> TLDR; You can now reuse documents via templates. More field types coming soon as well.
## Introducing Templates
It's day 2 of Launch Week, everybody 🙌 After introducing [Teams](https://documenso.com/blog/launch-week-2-day-1) yesterday, today we are looking at making Documenso faster for daily use:
We are launching templates for Documenso! Templates are an easy way to reuse documents you send out often with just a few clicks. With templates, you can:
<figure>
<MdxNextImage
src="/blog/quickfill.png"
width="1260"
height="630"
alt="Template recipients quick fill view"
/>
<figcaption className="text-center">
Quickly fill out recipients, when creating from a template
</figcaption>
</figure>
- Save often-uploaded documents for reuse
- Pre-define fields, so you just have to send the document
- Quickly fill out recipients and roles for new documents
- Share templates with your team to make working together even easier
<figure>
<MdxNextImage
src="/blog/template.png"
width="1260"
height="630"
alt="Create from template view"
/>
<figcaption className="text-center">
POV: You are a diligent german and create custom receipts with Documenso
</figcaption>
</figure>
## Pricing
Templates are **included in all Documenso Plans!** That includes our free tier: The limit of 5 documents per month still applies, but you are free to reach it with less friction using templates. Sharing templates with other users is only possible with the teams plan. If you want to share templates with people not in your team, we might have something coming up later this week 👀
## What's Next for Templates
We have a lot of great stuff coming up for templates as well:
- More Field Types are in the pipeline
- Sharing Templates Externally 👀
Check out templates [here](https://documen.so/templates) and let us know what you think and what we can improve. Which field types are you missing? Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 2! They just launched templates, and I'm pumped 🎉🚀🚀🚀. Check it out https://documen.so/day2"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur

View File

@ -1,53 +0,0 @@
---
title: Launch Week II - Day 3 - API
description: Documenso's mission is to create a plattform developers all around the world can build upon. Today we are releasing the first version of our public API, included in all plans!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-28
tags:
- Launch Week
- API
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/cb74d6cb-a127-4cac-a166-ad6b56c6140d"
autoPlay
loop
muted
></video>
> TLDR; The public API is now availible for all plans.
## Introducing the public Documenso API
Launch. Week. Day. 3 🎉 Documenso's mission is to create a platform that developers all around the world can build upon. Today we are releasing the first version of our public API, and we are pumped. Since this is the first version, we focused on the basics. With the new API you can:
- Get Documents (Individual or all Accessible)
- Upload Documents
- Delete Documents
- Create Documents from Templates
- Trigger Sending Documents for Singing
You can check out the detailed API documentation here:
> API DOCUMENTATION: [https://app.documenso.com/api/v1/openapi](https://app.documenso.com/api/v1/openapi)
## Pricing
We are building Documenso to be an open and extendable platform; therefore the API is included in all current plans. The API is authenticated via auth tokens, which every user can create at no extra cost, as can teams. Existing limits still apply (i.e., the number of included documents for the free plan). While we don't have all the details yet, we don't intend to price the API usage in itself (rather the accounts using it) since we want you to build on Documenso without being smothered by API costs.
> Try the API here for free: [https://documen.so/api](https://documen.so/api)
## What's next for the API
You tell us. This is by far the most requested feature, so we would like to hear from you. What should we add? How can we integrate even better?
Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 3! The public API is here 👀 Check it out https://documen.so/day3"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur

View File

@ -1,63 +0,0 @@
---
title: Launch Week II - Day 4 - Webhooks and Zapier
description: If you want to integrate Documenso without fiddling with the API, we got you as well. You can now integrate Documenso via Zapier, included in all plans!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-29
tags:
- Launch Week
- Zapier
- Webhooks
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/3b60789d-8d27-4c66-ae5c-179e33c2e3e6"
autoPlay
loop
muted
></video>
> TLDR; Zapier Integration is now available for all plans.
## Introducing Zapier for Documenso
Day 4 🥳 Yesterday we introduced our [public API](https://documen.so/day3) for developers to build on Documenso. If you are not a developer or simple want a quicker integration this is for you: Documenso now support Zapier Integrations! Just connect your Documenso account via a simple login flow with Zapier and you will have access to Zapier's universe of integrations 💫 The integration currently supports:
- Document Created ([https://documen.so/zapier-created](https://documen.so/zapier-created))
- Document Sent ([Chttps://documen.so/zapier-sent](https://documen.so/zapier-sent))
- Document Opened ([https://documen.so/zapier-opened](https://documen.so/zapier-opened))
- Document Signed ([https://documen.so/zapier-signed](https://documen.so/zapier-signed))
- Document Completed ([https://documen.so/zapier-completed](https://documen.so/zapier-completed))
> ⚡️ You can create your own Zaps here: https://zapier.com/apps/documenso/integrations
Each event comes with extensive meta-data for you to use in Zapier. Missing something? Reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord). We're always here and would love to hear from you :)
## Also Introducing for Documenso: Webhooks
To build the Zapier Integration, we needed a good webhooks concept, so we added that as well. Together with your Zaps, you can also now create customer webhooks in Documenso. You can try webhooks here for free: [https://documen.so/webhooks](https://documen.so/webhooks)
<figure>
<MdxNextImage
src="/blog/hooks.png"
width="1260"
height="630"
alt="Webhooks UI"
/>
<figcaption className="text-center">
Create unlimited custom webhooks with each plan.
</figcaption>
</figure>
## Pricing
Just like the API, we consider the Zapier integration and webhooks part of the open Documenso platform. Zapier is **available for all Documenso plans**, including free! [Login now](https://documen.so/login) to check it out.
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 4! Documenso now supports @Zapier 🤯 https://documen.so/day4"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur

View File

@ -1,61 +0,0 @@
---
title: Launch Week II - Day 5 - Documenso Profiles
description: Documenso profiles allow you to send signing links to people so they can sign anytime and see who you are. Documenso Profile Usernames can be claimed starting today. Long ones free, short ones paid. Profiles will launch as soon as they are shiny.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-03-01
tags:
- Launch Week
- Profiles
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/89643ae2-2aa9-484c-a522-a0e35097c469"
autoPlay
loop
muted
></video>
> TLDR; Documenso profiles allow you to send signing links to people so they can sign anytime and see who you are. Documenso Profile Usernames can be claimed starting today. Long ones free, short ones paid. Profiles launch as soon as they are shiny.
## Introducing Documenso Profile Links
Day 5 - The Finale 🔥
Signing documents has always been between humans, and signing something together should be as frictionless as possible. It should also be async, so you don't force your counterpart to jog to their device to send something when you are ready. Today we are announcing the new Documenso Profiles:
<figure>
<MdxNextImage
src="/blog/profile.png"
width="1260"
height="813"
alt="Timur's Documenso Profile"
/>
<figcaption className="text-center">
Async > Sync: Add public templates to your Documenso Link and let people sign whenever they are ready.
</figcaption>
</figure>
Documenso profiles work with your existing templates. You can just add them to your public profile to let everyone with your link sign them. With profiles, we want to bring back the human aspect of signing.
By making profiles public, you can always access what your counterparty offers and make them more visible in the process. Long-term, we plan to add more to profiles to help you ensure the person you are dealing with is who they claim to be. Documenso wants to be the trust layer of the internet, and we want to start at the very fundamental level: The individual transaction.
Profiles are our first step towards bringing more trust into everything, simply by making the use of signing more frictionless. As there is more and more content of questionable origin out there, we want to support you in making it clear what you send out and what not.
## Pricing and Claiming
Documenso profile username can be claimed starting today. Documenso profiles will launch as soon as we are happy with the details ✨
- Long usernames (6 characters or more) come free with every account, e.g. **documenso.com/u/timurercan**
- Short usernames (5 characters or fewer) or less require any paid account ([Early Adopter](https://documen.so/claim-early-adopters-plan), [Teams](https://documen.so/teams) or Enterprise): **e.g., documenso.com/u/timur**
You can claim your username here: [https://documen.so/claim](https://documen.so/claim)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 5! You can now claim your username for the upcoming profile links 😮 https://documen.so/day5"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur

View File

@ -1,292 +0,0 @@
---
title: 'Building the Documenso Public API - The Why and How'
description: 'This article talks about the need for the public API and the process of building it. It also discusses the requirements we had to meet and the constraints we had to work within.'
authorName: 'Catalin'
authorImage: '/blog/blog-author-catalin.webp'
authorRole: 'I like to code and write'
date: 2024-03-08
tags:
- Development
- API
---
This article covers the process of building the public API for Documenso. It starts by explaining why the API was needed for a digital document signing company in the first place. Then, it'll dive into the steps we took to build it. Lastly, it'll present the requirements we had to meet and the constraints we had to work within.
## Why the public API
We decided to build the public API to open a new way of interacting with Documenso. While the web app does the job well, there are use cases where it's not enough. In those cases, the users might want to interact with the platform programmatically. Usually, that's for integrating Documenso with other applications.
With the new public API that's now possible. You can integrate Documenso's functionalities within other applications to automate tasks, create custom solutions, and build custom workflows, to name just a few.
The API provides 12 endpoints at the time of writing this article:
- (GET) `/api/v1/documents` - retrieve all the documents
- (POST) `/api/v1/documents` - upload a new document and getting a presigned URL
- (GET) `/api/v1/documents/{id}` - fetch a specific document
- (DELETE) `/api/v1/documents/{id}` - delete a specific document
- (POST) `/api/v1/templates/{templateId}/create-document` - create a new document from an existing template
- (POST) `/api/v1/documents/{id}/send` - send a document for signing
- (POST) `/api/v1/documents/{id}/recipients` - create a document recipient
- (PATCH) `/api/v1/documents/{id}/recipients/{recipientId}` - update the details of a document recipient
- (DELETE) `/api/v1/documents/{id}/recipients/{recipientId}` - delete a specific recipient from a document
- (POST) `/api/v1/documents/{id}/fields` - create a field for a document
- (PATCH) `/api/v1/documents/{id}/fields` - update the details of a document field
- (DELETE) `/api/v1/documents/{id}/fields` - delete a field from a document
> Check out the [API documentation](https://app.documenso.com/api/v1/openapi).
Moreover, it also enables us to enhance the platform by bringing other integrations to Documenso, such as Zapier.
In conclusion, the new public API extends Documenso's capabilities, provides more flexibility for users, and opens up a broader world of possibilities.
## Picking the right approach & tech
Once we decided to build the API, we had to choose the approach and technologies to use. There were 2 options:
1. Build an additional application
2. Launch the API in the existing codebase
### 1. Build an additional application
That would mean creating a new codebase and building the API from scratch. Having a separate app for the API would result in benefits such as:
- lower latency responses
- supporting larger field uploads
- separation between the apps (Documenso and the API)
- customizability and flexibility
- easier testing and debugging
This approach has significant benefits. However, one major drawback is that it requires additional resources.
We'd have to spend a lot of time just on the core stuff, such as building and configuring the basic server. After that, we'd spend time implementing the endpoints and authorization, among other things. When the building is done, there will be another application to deploy and manage. All of this would stretch our already limited resources.
So, we asked ourselves if there is another way of doing it without sacrificing the API quality and the developer experience.
### 2. Launch the API in the existing codebase
The other option was to launch the API in the existing codebase. Rather than writing everything from scratch, we could use most of our existing code.
Since we're using tRPC for our internal API (backend), we looked for solutions that work well with tRPC. We narrowed down the choices to:
- [trpc-openapi](https://github.com/jlalmes/trpc-openapi)
- [ts-rest](https://ts-rest.com/)
Both technologies allow you to build public APIs. The `trpc-openapi` technology allows you to easily turn tRPC procedures into REST endpoints. It's more like a plugin for tRPC.
On the other hand, `ts-rest` is more of a standalone solution. `ts-rest` enables you to create a contract for the API, which can be used both on the client and server. You can consume and implement the contract in your application, thus providing end-to-end type safety and RPC-like client.
> You can see a [comparison between trpc-openapi and ts-rest](https://catalins.tech/public-api-trpc/) here.
So, the main difference between the 2 is that `trpc-openapi` is like a plugin that extends tRPC's capabilities, whereas `ts-rest` provides the tools for building a standalone API.
### Our choice
After analyzing and comparing the 2 options, we decided to go with `ts-rest` because of its benefits. Here's a paragraph from the `ts-rest` documentation that hits the nail on the head:
> tRPC has many plugins to solve this issue by mapping the API implementation to a REST-like API, however, these approaches are often a bit clunky and reduce the safety of the system overall, ts-rest does this heavy lifting in the client and server implementations rather than requiring a second layer of abstraction and API endpoint(s) to be defined.
## API Requirements
We defined the following requirements for the API:
- The API should use path-based versioning (e.g. `/v1`)
- The system should use bearer tokens for API authentication
- The API token should be a random string of 32 to 40 characters
- The system should hash the token and store the hashed value
- The system should only display the API token when it's created
- The API should have self-generated documentation like Swagger
- Users should be able to create an API key
- Users should be able to choose a token name
- Users should be able to choose an expiration date for the token
- User should be able to choose between 7 days, 1 month, 3 months, 6 months, 12 months, never
- System should display all the user's tokens in the settings page
- System should display the token name, creation date, expiration date and a delete button
- Users should be able to delete an API key
- Users should be able to retrieve all the documents from their account
- Users should be able to upload a new document
- Users should receive an S3 pre-signed URL after a successful upload
- Users should be able to retrieve a specific document from their account by its id
- Users should be able to delete a specific document from their account by its id
- Users should be able to create a new document from an existing document template
- Users should be able to send a document for signing to 1 or more recipients
- Users should be able to create a recipient for a document
- Users should be able to update the details of a recipient
- Users should be able to delete a recipient from a document
- Users should be able to create a field (e.g. signature, email, name, date) for a document
- Users should be able to update a field for a document
- Users should be able to delete a field from a document
## Constraints
We also faced the following constraints while developing the API:
**1. Resources**
Limited resources were one of the main constraints. We're a new startup with a relatively small team. Building and maintaining an additional application would strain our limited resources.
**2. Technology stack**
Another constraint was the technology stack. Our tech stack includes TypeScript, Prisma, and tRPC, among others. We also use Vercel for hosting.
As a result, we wanted to use technologies we are comfortable with. This allowed us to leverage our existing knowledge and ensured consistency across our applications.
Using familiar technologies also meant we could develop the API faster, as we didn't have to spend time learning new technologies. We could also leverage existing code and tools used in our main application.
It's worth mentioning that this is not a permanent decision. We're open to moving the API to another codebase/tech stack when it makes sense (e.g. API is heavily used and needs better performance).
**3. File uploads**
Due to our current architecture, we support file uploads with a maximum size of 50 MB. To circumvent this, we created an additional step for uploading documents.
Users make a POST request to the `/api/v1/documents` endpoint and the API responds with an S3 pre-signed URL. The users then make a 2nd request to the pre-signed URL with their document.
## How we built the API
![API package diagram](api-package.webp)
Our codebase is a monorepo, so we created a new API package in the `packages` directory. It contains both the API implementation and its documentation. The main 2 blocks of the implementation consist of the API contract and the code for the API endpoints.
![API implementation diagram](api-implementation.webp)
In a few words, the API contract defines the API structure, the format of the requests and responses, how to authenticate API calls, the available endpoints and their associated HTTP verbs. You can explore the [API contract](https://github.com/documenso/documenso/blob/main/packages/api/v1/contract.ts) on GitHub.
Then, there's the implementation part, which is the actual code for each endpoint defined in the API contract. The implementation is where the API contract is brought to life and made functional.
Let's take the endpoint `/api/v1/documents` as an example.
```ts
export const ApiContractV1 = c.router(
{
getDocuments: {
method: 'GET',
path: '/api/v1/documents',
query: ZGetDocumentsQuerySchema,
responses: {
200: ZSuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Get all documents',
},
...
}
);
```
The API contract specifies the following things for `getDocuments`:
- the allowed HTTP request method is GET, so trying to make a POST request, for example, results in an error
- the path is `/api/v1/documents`
- the query parameters the user can pass with the request
- in this case - `page` and `perPage`
- the allowed responses and their schema
- `200` returns an object containing an array of all documents and a field `totalPages`, which is self-explanatory
- `401` returns an object with a message such as "Unauthorized"
- `404` returns an object with a message such as "Not found"
The implementation of this endpoint needs to match the contract completely; otherwise, `ts-rest` will complain, and your API might not work as intended.
The `getDocuments` function from the `implementation.ts` file runs when the user hits the endpoint.
```ts
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
getDocuments: authenticatedMiddleware(async (args, user, team) => {
const page = Number(args.query.page) || 1;
const perPage = Number(args.query.perPage) || 10;
const { data: documents, totalPages } = await findDocuments({
page,
perPage,
userId: user.id,
teamId: team?.id,
});
return {
status: 200,
body: {
documents,
totalPages,
},
};
}),
...
});
```
There is a middleware, too, `authenticatedMiddleware`, that handles the authentication for API requests. It ensures that the API token exists and the token used has the appropriate privileges for the resource it accesses.
That's how the other endpoints work as well. The code differs, but the principles are the same. You can explore the [API implementation](https://github.com/documenso/documenso/blob/main/packages/api/v1/implementation.ts) and the [middleware code](https://github.com/documenso/documenso/blob/main/packages/api/v1/middleware/authenticated.ts) on GitHub.
### Documentation
For the documentation, we decided to use Swagger UI, which automatically generates the documentation from the OpenAPI specification.
The OpenAPI specification describes an API containing the available endpoints and their HTTP request methods, authentication methods, and so on. Its purpose is to help both machines and humans understand the API without having to look at the code.
The Documenso OpenAPI specification is live [here](https://documenso.com/api/v1/openapi.json).
Thankfully, `ts-rest` makes it seamless to generate the OpenAPI specification.
```ts
import { generateOpenApi } from '@ts-rest/open-api';
import { ApiContractV1 } from './contract';
export const OpenAPIV1 = generateOpenApi(
ApiContractV1,
{
info: {
title: 'Documenso API',
version: '1.0.0',
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
},
},
{
setOperationId: true,
},
);
```
Then, the Swagger UI takes the OpenAPI specification as a prop and generates the documentation. The code below shows the component responsible for generating the documentation.
```ts
'use client';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
export const OpenApiDocsPage = () => {
return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />;
};
export default OpenApiDocsPage;
```
Lastly, we create an API endpoint to display the Swagger documentation. The code below dynamically imports the `OpenApiDocsPage` component and displays it.
```ts
'use client';
import dynamic from 'next/dynamic';
const Docs = dynamic(async () => import('@documenso/api/v1/api-documentation'), {
ssr: false,
});
export default function OpenApiDocsPage() {
return <Docs />;
}
```
You can access and play around with the documentation at [documenso.com/api/v1/openapi](https://documenso.com/api/v1/openapi). You should see a page like the one shown in the screenshot below.
![The documentation for the Documenso API](docs.webp)
> This article shows how to [generate Swagger documentation for a Next.js API](https://catalins.tech/generate-swagger-documentation-next-js-api/).
So, that's how we went about building the first iteration of the public API after taking into consideration all the constraints and the current needs. The [GitHub pull request for the API](https://github.com/documenso/documenso/pull/674) is publicly available on GitHub. You can go through it at your own pace.
## Conclusion
The current architecture and approach work well for our current stage and needs. However, as we continue to grow and evolve, our architecture and approach will likely need to adapt. We monitor API usage and performance regularly and collect feedback from users. This enables us to find areas for improvement, understand our users' needs, and make informed decisions about the next steps.

View File

@ -1,56 +0,0 @@
---
title: 'Embracing the Future and Moving Back Again: From Server Actions to tRPC'
description: 'This article talks about the need for the public API and the process of building it. It also talks about the requirements, constraints, challenges and lessons learnt from building it.'
authorName: 'Lucas Smith'
authorImage: '/blog/blog-author-lucas.png'
authorRole: 'Co-Founder'
date: 2024-03-07
tags:
- Development
---
On October 26, 2022, during the Next.js Conf, Vercel unveiled Next.js 13, introducing a bunch of new features and a whole new paradigm shift for creating Next.js apps.
React Server Components (RSCs) have been known for some time but haven't yet been implemented in a React meta-framework. With Next.js now adding them, the world of server-side rendering React applications was about to change.
## The promise of React Server Components
Described by Vercel as a tool for writing UIs rendered or optionally cached on the server, RSC promised direct integration with databases and server-specific capabilities, a departure from the typical React and SSR models.
This feature initially appeared as a game-changer, promising simpler data fetching and routing, thanks to async server components and additional nested layout support.
After experimenting with it for about six months on other projects, I came to the conclusion that while it was a little rough at the edges, it was indeed a game changer and something that should be adopted in Next.js projects moving forward.
## A new new paradigm: Server Actions
Vercel didn't stop at adding Server Components. A short while after the initial Next.js 13 release, they introduced "Server Actions." Server Actions allow for the calling of server-side functions without API routes, reducing the amount of ceremony required for adding a new mutation or event to your application.
## Betting on new technology
Following the release of both Server Actions and Server Components, we at Documenso embarked on a rewrite of the application. We had accumulated a bit of technical debt from the initial MVP, which was best shed as we also improved the design with help from our new designer.
Having had much success with Server Components on other projects, I opted to go all in on both Server Components and Server Actions for the new version of the application. I was excited to see how much simpler and more efficient the application would be with these new technologies.
Following our relaunch, we felt quite happy with our choices of server actions, which even let us cocktail certain client—and server-side logic into a single component without needing a bunch of effort on our end.
## Navigating challenges with Server Actions
While initially successful, we soon encountered issues with Server Actions, particularly when monitoring the application. Unfortunately, server actions make it much harder to monitor network requests and identify failed server actions since they use the route that they are currently on.
Despite this issue, things were manageable; however, a critical issue arose when the usage of server actions caused bundle corruption, resulting in a top-level await insertion that would cause builds and tests to fail.
This last issue was a deal breaker since it highlighted how much magic we were relying on with server actions. I like to avoid adding more magic where possible since, with bundlers and meta-frameworks, we are already handing off a lot of heavy lifting and getting a lot of magic in return.
## Going all in on tRPC
My quick and final solution to the issues we were facing was to fully embrace [tRPC](https://trpc.io/), which we already used in other app areas. The migration took less than a day and resolved all our issues while adding a lot more visibility to failing actions since they now had a dedicated API route.
For those unfamiliar with tRPC, it is a framework for building end-to-end typesafe APIs with TypeScript and Next.js. It's an awesome library that lets you call your defined API safely within the client, causing build time errors when things inevitably drift.
I've been a big fan of tRPC for some time now, so the decision to drop server actions and instead use more of tRPC was easy for me.
## Lessons learned and the future
While I believe server actions are a great idea, and I'm sure they will be improved in the future, I've relearned the lesson that it's best to avoid magic where possible.
This doesn't mean we won't consider moving certain things back to server actions in the future. But for now, we will wait and watch how others use them and how they evolve.

View File

@ -1,49 +0,0 @@
---
title: Sunsetting the Early Adopters Plan
description: We reached or Early Adopter cap and not transition to our regular pricing 🎉
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-06-12
tags:
- Early Adopters
- Pricing
- Open Startup
---
<figure>
<MdxNextImage
src="/blog/sunset.jpg"
width="1260"
height="630"
alt="A beautiful sunset as a metaphor for the Early Adopter phase ending"
/>
<figcaption className="text-center">
"Being early is, uh, good." -Unknown
</figcaption>
</figure>
> TLDR; The Early Adopters Plan ended, and we have a new pricing. If you are an Early Adopter, reach out for a Discord community badge 🏅
# The End of the Beginning
12 months, 13 releases, and 344 merged pull requests after announcing the Early Adopter plan in our first-ever launch week, and we hit the cap of 100. Documenso has changed and grown a lot since then. For us, this is a great milestone towards our broader mission of bringing open signing to the world since now we are joined by a community consisting of contributors and early customers all around the world.
# The New Plans
Starting today, we are sunsetting the Early Adopter Plan in favor of our new, more nuanced pricing model. The Early Adopter plan will succeeded by the **Individual plan**, which is still priced at $30/mo. The Individual plans will still include unlimited signatures and recipients since this aligns with our core belief of empowering our users wherever possible. If you managed to grab an Early Adopter plan, reach out on X or Discord to receive a special community badge. Early Adopters are meant to get preferential treatment where possible.
Previously soft-launched as part of Early Adopters, we are officially introducing the **Team Plan** to our pricing for customers requiring multi-user accounts. Priced at $50/ mo. for 5 users, this plan offers unlimited signature volume as well. Additional users can be added for $10/mo. as needed. We have carefully crafted the billing of teams to ensure that dynamic changes are accurately reflected at the end of each billing cycle, providing you with a fair-value pricing structure.
Our **Free Plan** stays unchanged, offering coverage to casual users and an easy way to try out Documenso or start developing.
Check out our [new pricing page here](https://documen.so/pricing). We also updated our [open page](https://documen.so/open) to reflect the end of Early Adopters. The metric now counts active subscriptions from Individuals and Teams.
# API Access
All plans include access to the API as per our philosophy, making Documenso an open platform, and allowing everyone to build on it, no matter how big or small. Besides the Free Plan's 5 signatures per month limit, the API does not have access restrictions. Even the free plan can keep using the API after using its signature volume for non-signing operations like reading, editing, and even creating documents. Since the individual plan technically allows for running a Fortune 500 company for $30/ mo., plan we are adding a fair use clause here: You are free to use the API "a lot" if you are a big organization trying to stay on the Individual Plan we will ask to have a word about upgrading (which might make sense anyway considering your requirements). Fair use excludes Early Adopters, which we consider limitless by any measure. If you need clarification on whether your case is covered under fair use, you can contact us on Discord or support@documenso.com. It's probably fine, though.
We also have a lot in the pipeline, and we are excited to share everything with you soon. A Big Shoutout to all Early Adopters. We salute you, and you will receive the preferred treatment where possible.
If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord).
Best from Hamburg\
Timur

View File

@ -12,7 +12,6 @@ export const BlogPost = defineDocumentType(() => ({
authorName: { type: 'string', required: true },
authorImage: { type: 'string', required: false },
authorRole: { type: 'string', required: true },
cta: { type: 'boolean', required: false, default: true },
},
computedFields: {
href: { type: 'string', resolve: (post) => `/${post._raw.flattenedPath}` },

View File

@ -2,7 +2,6 @@
const fs = require('fs');
const path = require('path');
const { withContentlayer } = require('next-contentlayer');
const { withAxiom } = require('next-axiom');
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
@ -18,15 +17,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
);
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
);
/** @type {import('next').NextConfig} */
const config = {
experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'),
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
serverActions: {
bodySizeLimit: '50mb',
},
@ -42,7 +36,6 @@ const config = {
env: {
NEXT_PUBLIC_PROJECT: 'marketing',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
},
modularizeImports: {
'lucide-react': {
@ -101,4 +94,4 @@ const config = {
},
};
module.exports = withAxiom(withContentlayer(config));
module.exports = withContentlayer(config);

View File

@ -19,18 +19,13 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
"@openstatus/react": "^0.0.3",
"contentlayer": "^0.3.4",
"embla-carousel": "^8.1.3",
"embla-carousel-autoplay": "^8.1.3",
"embla-carousel-react": "^8.1.3",
"framer-motion": "^10.12.8",
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "14.0.3",
"next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0",
@ -41,7 +36,7 @@
"react-hook-form": "^7.43.9",
"react-icons": "^4.11.0",
"recharts": "^2.7.2",
"sharp": "^0.33.1",
"sharp": "0.33.1",
"typescript": "5.2.2",
"zod": "^3.22.4"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 788 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -1,5 +1,4 @@
import type { TClaimPlanRequestSchema } from './types';
import { ZClaimPlanResponseSchema } from './types';
import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types';
export const claimPlan = async ({
name,

View File

@ -1,21 +1,25 @@
import { ImageResponse } from 'next/og';
import { NextResponse } from 'next/server';
import { allBlogPosts } from 'contentlayer/generated';
export const runtime = 'edge';
const IMAGE_SIZE = {
export const contentType = 'image/png';
export const IMAGE_SIZE = {
width: 1200,
height: 630,
};
export async function GET(_request: Request) {
const url = new URL(_request.url);
type BlogPostOpenGraphImageProps = {
params: { post: string };
};
const title = url.searchParams.get('title');
const author = url.searchParams.get('author');
export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGraphImageProps) {
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
if (!title || !author) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
if (!blogPost) {
return null;
}
// The long urls are needed for a compiler optimisation on the Next.js side, lifting this up
@ -45,10 +49,10 @@ export async function GET(_request: Request) {
<img src={logoImage} alt="logo" tw="h-8" />
<h1 tw="mt-8 text-6xl text-center flex items-center justify-center w-full max-w-[800px] font-bold text-center mx-auto">
{title}
{blogPost.title}
</h1>
<p tw="font-normal">Written by {author}</p>
<p tw="font-normal">Written by {blogPost.authorName}</p>
</div>
),
{

View File

@ -7,8 +7,6 @@ import { ChevronLeft } from 'lucide-react';
import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks';
import { CallToAction } from '~/components/(marketing)/call-to-action';
export const dynamic = 'force-dynamic';
export const generateMetadata = ({ params }: { params: { post: string } }) => {
@ -20,23 +18,11 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
};
}
// Use the url constructor to ensure that things are escaped as they should be
const searchParams = new URLSearchParams({
title: blogPost.title,
author: blogPost.authorName,
});
return {
title: {
absolute: `${blogPost.title} - Documenso Blog`,
},
description: blogPost.description,
openGraph: {
images: [`${blogPost.href}/opengraph?${searchParams.toString()}`],
},
twitter: {
images: [`${blogPost.href}/opengraph?${searchParams.toString()}`],
},
};
};
@ -56,57 +42,53 @@ export default function BlogPostPage({ params }: { params: { post: string } }) {
const MDXContent = useMDXComponent(post.body.code);
return (
<div>
<article className="prose dark:prose-invert mx-auto py-8">
<div className="mb-6 text-center">
<time dateTime={post.date} className="text-muted-foreground mb-1 text-xs">
{new Date(post.date).toLocaleDateString()}
</time>
<article className="prose dark:prose-invert mx-auto py-8">
<div className="mb-6 text-center">
<time dateTime={post.date} className="text-muted-foreground mb-1 text-xs">
{new Date(post.date).toLocaleDateString()}
</time>
<h1 className="text-3xl font-bold">{post.title}</h1>
<h1 className="text-3xl font-bold">{post.title}</h1>
<div className="not-prose relative -mt-2 flex items-center gap-x-4 border-b border-t py-4">
<div className="bg-foreground h-10 w-10 rounded-full">
{post.authorImage && (
<img
src={post.authorImage}
alt={`Image of ${post.authorName}`}
className="bg-foreground/10 h-10 w-10 rounded-full"
/>
)}
</div>
<div className="not-prose relative -mt-2 flex items-center gap-x-4 border-b border-t py-4">
<div className="bg-foreground h-10 w-10 rounded-full">
{post.authorImage && (
<img
src={post.authorImage}
alt={`Image of ${post.authorName}`}
className="bg-foreground/10 h-10 w-10 rounded-full"
/>
)}
</div>
<div className="text-sm leading-6">
<p className="text-foreground text-left font-semibold">{post.authorName}</p>
<p className="text-muted-foreground">{post.authorRole}</p>
</div>
<div className="text-sm leading-6">
<p className="text-foreground text-left font-semibold">{post.authorName}</p>
<p className="text-muted-foreground">{post.authorRole}</p>
</div>
</div>
</div>
<MDXContent components={mdxComponents} />
<MDXContent components={mdxComponents} />
{post.tags.length > 0 && (
<ul className="not-prose flex list-none flex-row space-x-2 px-0">
{post.tags.map((tag, i) => (
<li
key={`tag-${i}`}
className="bg-muted hover:bg-muted/60 text-foreground relative z-10 whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-medium"
>
{tag}
</li>
))}
</ul>
)}
{post.tags.length > 0 && (
<ul className="not-prose flex list-none flex-row space-x-2 px-0">
{post.tags.map((tag, i) => (
<li
key={`tag-${i}`}
className="bg-muted hover:bg-muted/60 text-foreground relative z-10 whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-medium"
>
{tag}
</li>
))}
</ul>
)}
<hr />
<hr />
<Link href="/blog" className="text-muted-foreground flex items-center hover:opacity-60">
<ChevronLeft className="mr-2 h-6 w-6" />
Back to all posts
</Link>
</article>
{post.cta && <CallToAction className="mt-8" utmSource={`blog_${params.post}`} />}
</div>
<Link href="/blog" className="text-muted-foreground flex items-center hover:opacity-60">
<ChevronLeft className="mr-2 h-6 w-6" />
Back to all posts
</Link>
</article>
);
}

View File

@ -4,8 +4,6 @@ import React, { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Footer } from '~/components/(marketing)/footer';
@ -19,10 +17,6 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
const [scrollY, setScrollY] = useState(0);
const pathname = usePathname();
const { getFlag } = useFeatureFlags();
const showProfilesAnnouncementBar = getFlag('marketing_profiles_announcement_bar');
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
@ -36,8 +30,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', {
'overflow-y-auto overflow-x-hidden':
pathname && !['/singleplayer', '/pricing'].includes(pathname),
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
})}
>
<div
@ -45,23 +38,6 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
'bg-background/50 backdrop-blur-md': scrollY > 5,
})}
>
{showProfilesAnnouncementBar && (
<div className="relative inline-flex w-full items-center justify-center overflow-hidden bg-[#e7f3df] px-4 py-2.5">
<div className="text-black text-center text-sm font-medium">
Claim your documenso public profile username now!{' '}
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
<a
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=marketing-announcement-bar`}
className="bg-background text-foreground rounded-md px-2.5 py-1 text-xs font-medium duration-300"
>
Claim Now
</a>
</div>
</div>
</div>
)}
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div>

View File

@ -1,10 +1,11 @@
'use client';
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { formatMonth } from '@documenso/lib/client-only/format-month';
import { cn } from '@documenso/ui/lib/utils';
export type BarMetricProps<T extends Record<string, unknown>> = HTMLAttributes<HTMLDivElement> & {
data: T;
@ -33,13 +34,13 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
.reverse();
return (
<div className={className} {...props}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">{title}</h3>
<span>{extraInfo}</span>
</div>
<div className={cn('flex flex-col', className)} {...props}>
<div className="flex items-center px-4">
<h3 className="text-lg font-semibold">{title}</h3>
<span>{extraInfo}</span>
</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">
<ResponsiveContainer width="100%" height={chartHeight}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />
@ -55,7 +56,6 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
<Bar
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
dataKey={metricKey as string}
maxBarSize={60}
fill="hsl(var(--primary))"

View File

@ -1,10 +1,11 @@
'use client';
import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import { HTMLAttributes, useEffect, useState } from 'react';
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts';
import { cn } from '@documenso/ui/lib/utils';
import { CAP_TABLE } from './data';
const COLORS = ['#7fd843', '#a2e771', '#c6f2a4'];
@ -47,12 +48,10 @@ export const CapTable = ({ className, ...props }: CapTableProps) => {
setIsSSR(false);
}, []);
return (
<div className={className} {...props}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Cap Table</h3>
</div>
<div className={cn('flex flex-col', className)} {...props}>
<h3 className="px-4 text-lg font-semibold">Cap Table</h3>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border shadow-sm hover:shadow">
{!isSSR && (
<PieChart width={400} height={400}>
<Pie

View File

@ -1,10 +1,11 @@
'use client';
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { formatMonth } from '@documenso/lib/client-only/format-month';
import { cn } from '@documenso/ui/lib/utils';
export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
data: Record<string, string | number>[];
@ -13,17 +14,14 @@ export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => {
const formattedData = data.map((item) => ({
amount: Number(item.amount),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
date: formatMonth(item.date as string),
}));
return (
<div className={className} {...props}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Total Funding Raised</h3>
</div>
<div className={cn('flex flex-col', className)} {...props}>
<h3 className="px-4 text-lg font-semibold">Total Funding Raised</h3>
<div className="border-border mt-2.5 flex flex-1 flex-col items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData} margin={{ top: 40, right: 40, bottom: 20, left: 40 }}>
<XAxis dataKey="date" />

View File

@ -1,4 +1,4 @@
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import { cn } from '@documenso/ui/lib/utils';

View File

@ -1,56 +0,0 @@
'use client';
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetCompletedDocumentsMonthlyResult } from '@documenso/lib/server-only/user/get-monthly-completed-document';
export type MonthlyCompletedDocumentsChartProps = {
className?: string;
data: GetCompletedDocumentsMonthlyResult;
};
export const MonthlyCompletedDocumentsChart = ({
className,
data,
}: MonthlyCompletedDocumentsChartProps) => {
const formattedData = [...data].reverse().map(({ month, count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
count: Number(count),
};
});
return (
<div className={className}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Completed Documents per Month</h3>
</div>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
formatter={(value) => [Number(value).toLocaleString('en-US'), 'Completed Documents']}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={60}
label="Completed Documents"
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@ -4,6 +4,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 { cn } from '@documenso/ui/lib/utils';
export type MonthlyNewUsersChartProps = {
className?: string;
@ -13,27 +14,24 @@ export type MonthlyNewUsersChartProps = {
export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartProps) => {
const formattedData = [...data].reverse().map(({ month, count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL'),
count: Number(count),
};
});
return (
<div className={className}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">New Users</h3>
</div>
<div className={cn('flex flex-col', className)}>
<div className="flex items-center px-4">
<h3 className="text-lg font-semibold">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">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
formatter={(value) => [Number(value).toLocaleString('en-US'), 'New Users']}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>

View File

@ -4,6 +4,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 { cn } from '@documenso/ui/lib/utils';
export type MonthlyTotalUsersChartProps = {
className?: string;
@ -13,27 +14,24 @@ export type MonthlyTotalUsersChartProps = {
export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersChartProps) => {
const formattedData = [...data].reverse().map(({ month, cume_count: count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL'),
count: Number(count),
};
});
return (
<div className={className}>
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Total Users</h3>
</div>
<div className={cn('flex flex-col', className)}>
<div className="flex items-center px-4">
<h3 className="text-lg font-semibold">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">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
formatter={(value) => [Number(value).toLocaleString('en-US'), 'Total Users']}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>

View File

@ -2,24 +2,19 @@ import type { Metadata } from 'next';
import { z } from 'zod';
import { getCompletedDocumentsMonthly } from '@documenso/lib/server-only/user/get-monthly-completed-document';
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { FUNDING_RAISED } from '~/app/(marketing)/open/data';
import { CallToAction } from '~/components/(marketing)/call-to-action';
import { MetricCard } from '~/app/(marketing)/open/metric-card';
import { SalaryBands } from '~/app/(marketing)/open/salary-bands';
import { BarMetric } from './bar-metrics';
import { CapTable } from './cap-table';
import { FundingRaised } from './funding-raised';
import { MetricCard } from './metric-card';
import { MonthlyCompletedDocumentsChart } from './monthly-completed-documents-chart';
import { MonthlyNewUsersChart } from './monthly-new-users-chart';
import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
import { SalaryBands } from './salary-bands';
import { TeamMembers } from './team-members';
import { OpenPageTooltip } from './tooltip';
import { TotalSignedDocumentsChart } from './total-signed-documents-chart';
import { Typefully } from './typefully';
export const metadata: Metadata = {
title: 'Open Startup',
@ -134,149 +129,123 @@ export default async function OpenPage() {
{ total_count: mergedPullRequests },
STARGAZERS_DATA,
EARLY_ADOPTERS_DATA,
MONTHLY_USERS,
MONTHLY_COMPLETED_DOCUMENTS,
] = await Promise.all([
fetchGithubStats(),
fetchOpenIssues(),
fetchMergedPullRequests(),
fetchStargazers(),
fetchEarlyAdopters(),
getUserMonthlyGrowth(),
getCompletedDocumentsMonthly(),
]);
const MONTHLY_USERS = await getUserMonthlyGrowth();
return (
<div>
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
<div className="flex flex-col items-center justify-center">
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
<div className="flex flex-col items-center justify-center">
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
<p className="text-muted-foreground mt-4 max-w-[60ch] text-center text-lg leading-normal">
All our metrics, finances, and learnings are public. We believe in transparency and want
to share our journey with you. You can read more about why here:{' '}
<a
className="font-bold"
href="https://documenso.com/blog/pre-seed"
target="_blank"
rel="noreferrer"
>
Announcing Open Metrics
</a>
</p>
</div>
<div className="my-12 grid grid-cols-12 gap-8">
<div className="col-span-12 grid grid-cols-4 gap-4">
<MetricCard
className="col-span-2 lg:col-span-1"
title="Stargazers"
value={stargazersCount.toLocaleString('en-US')}
/>
<MetricCard
className="col-span-2 lg:col-span-1"
title="Forks"
value={forksCount.toLocaleString('en-US')}
/>
<MetricCard
className="col-span-2 lg:col-span-1"
title="Open Issues"
value={openIssues.toLocaleString('en-US')}
/>
<MetricCard
className="col-span-2 lg:col-span-1"
title="Merged PR's"
value={mergedPullRequests.toLocaleString('en-US')}
/>
</div>
<TeamMembers className="col-span-12" />
<SalaryBands className="col-span-12" />
</div>
<h2 className="px-4 text-2xl font-semibold">Finances</h2>
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
<FundingRaised data={FUNDING_RAISED} className="col-span-12 lg:col-span-6" />
<CapTable className="col-span-12 lg:col-span-6" />
</div>
<h2 className="px-4 text-2xl font-semibold">Community</h2>
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="stars"
title="GitHub: Total Stars"
label="Stars"
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="mergedPRs"
title="GitHub: Total Merged PRs"
label="Merged PRs"
chartHeight={400}
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="forks"
title="GitHub: Total Forks"
label="Forks"
chartHeight={400}
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="openIssues"
title="GitHub: Total Open Issues"
label="Open Issues"
chartHeight={400}
className="col-span-12 lg:col-span-6"
/>
<Typefully className="col-span-12 lg:col-span-6" />
</div>
<h2 className="px-4 text-2xl font-semibold">Growth</h2>
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
<BarMetric<EarlyAdoptersType>
data={EARLY_ADOPTERS_DATA}
metricKey="earlyAdopters"
title="Total Customers"
label="Total Customers"
className="col-span-12 lg:col-span-6"
extraInfo={<OpenPageTooltip />}
/>
<MonthlyTotalUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
<MonthlyNewUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
<MonthlyCompletedDocumentsChart
data={MONTHLY_COMPLETED_DOCUMENTS}
className="col-span-12 lg:col-span-6"
/>
<TotalSignedDocumentsChart
data={MONTHLY_COMPLETED_DOCUMENTS}
className="col-span-12 lg:col-span-6"
/>
</div>
</div>
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
<h2 className="text-2xl font-bold">Is there more?</h2>
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal">
This page is evolving as we learn what makes a great signing company. We'll update it when
we have more to share.
<p className="text-muted-foreground mt-4 max-w-[60ch] text-center text-lg leading-normal">
All our metrics, finances, and learnings are public. We believe in transparency and want
to share our journey with you. You can read more about why here:{' '}
<a
className="font-bold"
href="https://documenso.com/blog/pre-seed"
target="_blank"
rel="noreferrer"
>
Announcing Open Metrics
</a>
</p>
</div>
<CallToAction className="mt-12" utmSource="open-page" />
<div className="mt-12 grid grid-cols-12 gap-8">
<div className="col-span-12 grid grid-cols-4 gap-4">
<MetricCard
className="col-span-2 lg:col-span-1"
title="Stargazers"
value={stargazersCount.toLocaleString('en-US')}
/>
<MetricCard
className="col-span-2 lg:col-span-1"
title="Forks"
value={forksCount.toLocaleString('en-US')}
/>
<MetricCard
className="col-span-2 lg:col-span-1"
title="Open Issues"
value={openIssues.toLocaleString('en-US')}
/>
<MetricCard
className="col-span-2 lg:col-span-1"
title="Merged PR's"
value={mergedPullRequests.toLocaleString('en-US')}
/>
</div>
<TeamMembers className="col-span-12" />
<SalaryBands className="col-span-12" />
<FundingRaised data={FUNDING_RAISED} className="col-span-12 lg:col-span-6" />
<CapTable className="col-span-12 lg:col-span-6" />
<BarMetric<EarlyAdoptersType>
data={EARLY_ADOPTERS_DATA}
metricKey="earlyAdopters"
title="Early Adopters"
label="Early Adopters"
className="col-span-12 lg:col-span-6"
extraInfo={<OpenPageTooltip />}
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="stars"
title="Github: Total Stars"
label="Stars"
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="mergedPRs"
title="Github: Total Merged PRs"
label="Merged PRs"
chartHeight={300}
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="forks"
title="Github: Total Forks"
label="Forks"
chartHeight={300}
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="openIssues"
title="Github: Total Open Issues"
label="Open Issues"
chartHeight={300}
className="col-span-12 lg:col-span-6"
/>
<MonthlyTotalUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
<MonthlyNewUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
<h2 className="text-2xl font-bold">Where's the rest?</h2>
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal">
We're still working on getting all our metrics together. We'll update this page as soon
as we have more to share.
</p>
</div>
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import { cn } from '@documenso/ui/lib/utils';
import {

View File

@ -1,4 +1,4 @@
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import { cn } from '@documenso/ui/lib/utils';
import {

View File

@ -29,7 +29,7 @@ export function OpenPageTooltip() {
</svg>
</TooltipTrigger>
<TooltipContent>
<p>Customers with an Active Subscriptions.</p>
<p>Active Subscriptions.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@ -1,56 +0,0 @@
'use client';
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetCompletedDocumentsMonthlyResult } from '@documenso/lib/server-only/user/get-monthly-completed-document';
export type TotalSignedDocumentsChartProps = {
className?: string;
data: GetCompletedDocumentsMonthlyResult;
};
export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocumentsChartProps) => {
const formattedData = [...data].reverse().map(({ month, cume_count: count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
count: Number(count),
};
});
return (
<div className={className}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Total Completed Documents</h3>
</div>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
formatter={(value) => [
Number(value).toLocaleString('en-US'),
'Total Completed Documents',
]}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={60}
label="Total Completed Documents"
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@ -1,40 +0,0 @@
'use client';
import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { FaXTwitter } from 'react-icons/fa6';
import { Button } from '@documenso/ui/primitives/button';
export type TypefullyProps = HTMLAttributes<HTMLDivElement>;
export const Typefully = ({ className, ...props }: TypefullyProps) => {
return (
<div className={className} {...props}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Twitter Stats</h3>
</div>
<div className="my-12 flex flex-col items-center gap-y-4 text-center">
<FaXTwitter className="h-12 w-12" />
<Link href="https://typefully.com/documenso/stats" target="_blank">
<h1>Documenso on X</h1>
</Link>
<Button className="rounded-full" size="sm" asChild>
<Link href="https://typefully.com/documenso/stats" target="_blank">
View all stats
</Link>
</Button>
<Button className="rounded-full bg-white" size="sm" asChild>
<Link href="https://twitter.com/documenso" target="_blank">
Follow us on X
</Link>
</Button>
</div>
</div>
</div>
);
};

View File

@ -2,14 +2,13 @@
import Link from 'next/link';
import type { Variants } from 'framer-motion';
import { motion } from 'framer-motion';
import { Variants, motion } from 'framer-motion';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
import type { TOSSFriendsSchema } from './schema';
import { TOSSFriendsSchema } from './schema';
const ContainerVariants: Variants = {
initial: {

View File

@ -9,7 +9,6 @@ import {
} from '@documenso/ui/primitives/accordion';
import { Button } from '@documenso/ui/primitives/button';
import { Enterprise } from '~/components/(marketing)/enterprise';
import { PricingTable } from '~/components/(marketing)/pricing-table';
export const metadata: Metadata = {
@ -43,10 +42,6 @@ export default function PricingPage() {
<PricingTable />
</div>
<div className="mt-12">
<Enterprise />
</div>
<div className="mx-auto mt-36 max-w-2xl">
<h2 className="text-center text-2xl font-semibold">
None of these work for you? Try self-hosting!

View File

@ -27,7 +27,7 @@ export default async function SinglePlayerModeSuccessPage({
return notFound();
}
const signatures = await getRecipientSignatures({ recipientId: document.Recipient[0].id });
const signatures = await getRecipientSignatures({ recipientId: document.Recipient.id });
return <SinglePlayerModeSuccess document={document} signatures={signatures} />;
}

View File

@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@ -115,7 +115,7 @@ export const SinglePlayerClient = () => {
}
try {
const putFileData = await putPdfFile(uploadedFile.file);
const putFileData = await putFile(uploadedFile.file);
const documentToken = await createSinglePlayerDocument({
documentData: {
@ -158,11 +158,9 @@ export const SinglePlayerClient = () => {
expired: null,
signedAt: null,
readStatus: 'OPENED',
documentDeletedAt: null,
signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT',
role: 'SIGNER',
authOptions: null,
};
const onFileDrop = async (file: File) => {
@ -193,7 +191,7 @@ export const SinglePlayerClient = () => {
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
Create a{' '}
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
@ -205,7 +203,7 @@ export const SinglePlayerClient = () => {
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
early adopter plan
community plan
</Link>{' '}
for exclusive features, including the ability to collaborate with multiple signers.
</p>
@ -248,8 +246,6 @@ export const SinglePlayerClient = () => {
recipients={uploadedFile ? [placeholderRecipient] : []}
fields={fields}
onSubmit={onFieldsSubmit}
canGoBack={true}
isDocumentPdfLoaded={true}
/>
</fieldset>
@ -260,7 +256,6 @@ export const SinglePlayerClient = () => {
fields={fields}
onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
requireCustomText={Boolean(fields.find((field) => field.type === 'TEXT'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/>
</Stepper>

View File

@ -2,9 +2,6 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { AxiomWebVitals } from 'next-axiom';
import { PublicEnvScript } from 'next-runtime-env';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
@ -65,11 +62,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<PublicEnvScript />
</head>
<AxiomWebVitals />
<Suspense>
<PostHogPageview />
</Suspense>

View File

@ -1,4 +1,4 @@
import type { MetadataRoute } from 'next';
import { MetadataRoute } from 'next';
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';

View File

@ -1,4 +1,4 @@
import type { MetadataRoute } from 'next';
import { MetadataRoute } from 'next';
import { allBlogPosts, allGenericPages } from 'contentlayer/generated';

View File

@ -1,31 +0,0 @@
import Link from 'next/link';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
type CallToActionProps = {
className?: string;
utmSource?: string;
};
export const CallToAction = ({ className, utmSource = 'generic-cta' }: CallToActionProps) => {
return (
<Card spotlight className={className}>
<CardContent className="flex flex-col items-center justify-center p-12">
<h2 className="text-center text-2xl font-bold">Join the Open Signing Movement</h2>
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center leading-normal">
Create your account and start using state-of-the-art document signing. Open and beautiful
signing is within your grasp.
</p>
<Button className="mt-8 rounded-full no-underline" size="lg" asChild>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=${utmSource}`} target="_blank">
Get started
</Link>
</Button>
</CardContent>
</Card>
);
};

View File

@ -34,18 +34,17 @@ export const Callout = ({ starCount }: CalloutProps) => {
return (
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4">
<Link href="https://app.documenso.com/signup?utm_source=marketing-callout">
<Button
type="button"
variant="outline"
className="rounded-full bg-transparent backdrop-blur-sm"
>
Try our Free Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
No Credit Card required
</span>
</Button>
</Link>
<Button
type="button"
variant="outline"
className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
>
Get the Early Adopters Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
$30/mo. forever!
</span>
</Button>
<Link
href="https://github.com/documenso/documenso"
@ -54,7 +53,7 @@ export const Callout = ({ starCount }: CalloutProps) => {
>
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
<LuGithub className="mr-2 h-5 w-5" />
Star on GitHub
Star on Github
{starCount && starCount > 0 && (
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
{starCount.toLocaleString('en-US')}

View File

@ -1,261 +0,0 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Autoplay from 'embla-carousel-autoplay';
import useEmblaCarousel from 'embla-carousel-react';
import { useTheme } from 'next-themes';
import { Card } from '@documenso/ui/primitives/card';
import { Progress } from '@documenso/ui/primitives/progress';
import { Slide } from './slide';
const SLIDES = [
{
label: 'Signing Process',
type: 'video',
srcLight: 'https://github.com/documenso/design/raw/main/marketing/signing.webm',
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/signing.webm',
},
{
label: 'Teams',
type: 'video',
srcLight: 'https://github.com/documenso/design/raw/main/marketing/teams.webm',
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/teams.webm',
},
{
label: 'Zapier',
type: 'video',
srcLight: 'https://github.com/documenso/design/raw/main/marketing/zapier.webm',
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/zapier.webm',
},
{
label: 'Webhooks',
type: 'video',
srcLight: 'https://github.com/documenso/design/raw/main/marketing/webhooks.webm',
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/webhooks.webm',
},
{
label: 'API',
type: 'video',
srcLight: 'https://github.com/documenso/design/raw/main/marketing/api.webm',
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/api.webm',
},
{
label: 'Profile',
type: 'video',
srcLight: 'https://github.com/documenso/design/raw/main/marketing/profile_teaser.webm',
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/profile_teaser.webm',
},
];
export const Carousel = () => {
const slides = SLIDES;
const [_isPlaying, setIsPlaying] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [progress, setProgress] = useState(0);
const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
const [autoplayDelay, setAutoplayDelay] = useState<number[]>([]);
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [
Autoplay({ playOnInit: true, delay: autoplayDelay[selectedIndex] || 5000 }),
]);
const [emblaThumbsRef, emblaThumbsApi] = useEmblaCarousel(
{
loop: true,
containScroll: 'keepSnaps',
dragFree: true,
},
[Autoplay({ playOnInit: true, delay: autoplayDelay[selectedIndex] || 5000 })],
);
const onThumbClick = useCallback(
(index: number) => {
if (!emblaApi || !emblaThumbsApi) return;
emblaApi.scrollTo(index);
},
[emblaApi, emblaThumbsApi],
);
const onSelect = useCallback(() => {
if (!emblaApi || !emblaThumbsApi) return;
setSelectedIndex(emblaApi.selectedScrollSnap());
emblaThumbsApi.scrollTo(emblaApi.selectedScrollSnap());
resetProgress();
const autoplay = emblaApi.plugins()?.autoplay;
if (autoplay) {
autoplay.reset();
}
}, [emblaApi, emblaThumbsApi, setSelectedIndex]);
const resetProgress = useCallback(() => {
setProgress(0);
}, []);
useEffect(() => {
const setVideoDurations = async () => {
const durations = await Promise.all(
videoRefs.current.map(
async (video) =>
new Promise<number>((resolve) => {
if (video) {
video.onloadedmetadata = () => resolve(video.duration * 1000);
} else {
resolve(5000);
}
}),
),
);
setAutoplayDelay(durations);
};
void setVideoDurations();
}, [slides, mounted, resolvedTheme]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const video = entry.target as HTMLVideoElement;
video
.play()
.catch((error) => console.log('Error attempting to play the video:', error));
} else {
const video = entry.target as HTMLVideoElement;
video.pause();
}
});
},
{
threshold: 0.5,
},
);
videoRefs.current.forEach((video) => {
if (video) {
observer.observe(video);
}
});
return () => {
observer.disconnect();
};
}, [slides, mounted, resolvedTheme]);
useEffect(() => {
if (!emblaApi) return;
onSelect();
emblaApi.on('select', onSelect).on('reInit', onSelect);
}, [emblaApi, onSelect, mounted, resolvedTheme]);
useEffect(() => {
const autoplay = emblaApi?.plugins()?.autoplay;
if (!autoplay) return;
setIsPlaying(autoplay.isPlaying());
emblaApi
.on('autoplay:play', () => setIsPlaying(true))
.on('autoplay:stop', () => setIsPlaying(false))
.on('reInit', () => setIsPlaying(autoplay.isPlaying()));
}, [emblaApi, mounted, resolvedTheme]);
useEffect(() => {
if (autoplayDelay[selectedIndex] === undefined) return;
const updateInterval = 50;
const increment = 100 / (autoplayDelay[selectedIndex] / updateInterval);
let progressValue = 0;
const timer = setInterval(() => {
setProgress((prevProgress) => {
progressValue = prevProgress + increment;
if (progressValue >= 100) {
clearInterval(timer);
if (emblaApi) {
emblaApi.scrollNext();
}
return 100;
}
return progressValue;
});
}, updateInterval);
return () => clearInterval(timer);
}, [selectedIndex, autoplayDelay, emblaApi, mounted, resolvedTheme]);
useEffect(() => {
if (!emblaApi) return;
const resetCarousel = () => {
emblaApi.reInit();
emblaApi.scrollTo(0);
};
resetCarousel();
}, [emblaApi, autoplayDelay, mounted, resolvedTheme]);
// Ensure the component renders only after mounting to avoid theme issues
if (!mounted) return null;
return (
<>
<Card className="mx-auto mt-12 w-full max-w-4xl rounded-2xl p-1 before:rounded-2xl" gradient>
<div className="overflow-hidden rounded-xl" ref={emblaRef}>
<div className="flex touch-pan-y rounded-xl">
{slides.map((slide, index) => (
<div className="min-w-[10rem] flex-none basis-full rounded-xl" key={index}>
{slide.type === 'video' && (
<video
key={`${resolvedTheme}-${index}`}
ref={(el) => (videoRefs.current[index] = el)}
muted
loop
className="h-auto w-full rounded-xl"
>
<source
src={resolvedTheme === 'dark' ? slide.srcDark : slide.srcLight}
type="video/webm"
/>
Your browser does not support the video tag.
</video>
)}
</div>
))}
</div>
</div>
<div className="dark:bg-background absolute bottom-2 right-2 flex w-[5%] flex-col items-center space-y-1 rounded-lg bg-white p-1.5">
<span className="text-foreground dark:text-muted-foreground text-xs">
{selectedIndex + 1}/{slides.length}
</span>
<Progress value={progress} className="h-1" />
</div>
</Card>
<div className="mx-auto mt-12 max-w-4xl px-2">
<div className="mt-2 flex justify-between" ref={emblaThumbsRef}>
{slides.map((slide, index) => (
<Slide
key={index}
onClick={() => onThumbClick(index)}
selected={index === selectedIndex}
index={index}
label={slide.label}
/>
))}
</div>
</div>
</>
);
};

View File

@ -1,36 +0,0 @@
'use client';
import Link from 'next/link';
import { usePlausible } from 'next-plausible';
import { Button } from '@documenso/ui/primitives/button';
export const Enterprise = () => {
const event = usePlausible();
return (
<div className="mx-auto mt-36 max-w-2xl">
<h2 className="text-center text-2xl font-semibold">
Enterprise Compliance, License or Technical Needs?
</h2>
<p className="text-muted-foreground mt-4 text-center leading-relaxed">
Our Enterprise License is great large organizations looking to switch to Documenso for all
their signing needs. It's availible for our cloud offering as well as self-hosted setups and
offer a wide range of compliance and Adminstration Features.
</p>
<div className="mt-4 flex justify-center">
<Link
href="https://dub.sh/enterprise"
target="_blank"
className="mt-6"
onClick={() => event('enterprise-contact')}
>
<Button className="rounded-full text-base">Contact Us</Button>
</Link>
</div>
</div>
);
};

View File

@ -1,4 +1,4 @@
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import Image from 'next/image';

View File

@ -13,8 +13,6 @@ import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
// import { StatusWidgetContainer } from './status-widget-container';
export type FooterProps = HTMLAttributes<HTMLDivElement>;
const SOCIAL_LINKS = [
@ -64,10 +62,6 @@ export const Footer = ({ className, ...props }: FooterProps) => {
</Link>
))}
</div>
{/* <div className="mt-6">
<StatusWidgetContainer />
</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">

View File

@ -9,7 +9,6 @@ import Link from 'next/link';
import LogoImage from '@documenso/assets/logo.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { HamburgerMenu } from './mobile-hamburger';
import { MobileNavigation } from './mobile-navigation';
@ -69,18 +68,12 @@ export const Header = ({ className, ...props }: HeaderProps) => {
</Link>
<Link
href="https://app.documenso.com/signin?utm_source=marketing-header"
href="https://app.documenso.com/signin"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
Sign in
</Link>
<Button className="rounded-full" size="sm" asChild>
<Link href="https://app.documenso.com/signup?utm_source=marketing-header" target="_blank">
Sign up
</Link>
</Button>
</div>
<HamburgerMenu

View File

@ -3,8 +3,7 @@
import Image from 'next/image';
import Link from 'next/link';
import type { Variants } from 'framer-motion';
import { motion } from 'framer-motion';
import { Variants, motion } from 'framer-motion';
import { usePlausible } from 'next-plausible';
import { LuGithub } from 'react-icons/lu';
import { match } from 'ts-pattern';
@ -14,7 +13,7 @@ import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-fl
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Carousel } from './carousel';
import { Widget } from './widget';
export type HeroProps = {
className?: string;
@ -50,21 +49,6 @@ const HeroTitleVariants: Variants = {
},
};
const HeroCarouselVariants: Variants = {
initial: {
opacity: 0,
y: 60,
},
animate: {
opacity: 1,
y: 0,
transition: {
delay: 0.5,
duration: 0.8,
},
},
};
export const Hero = ({ className, ...props }: HeroProps) => {
const event = usePlausible();
@ -72,6 +56,23 @@ export const Hero = ({ className, ...props }: HeroProps) => {
const heroMarketingCTA = getFlag('marketing_landing_hero_cta');
const onSignUpClick = () => {
const el = document.getElementById('email');
if (el) {
const { top } = el.getBoundingClientRect();
window.scrollTo({
top: top - 120,
behavior: 'smooth',
});
requestAnimationFrame(() => {
el.focus();
});
}
};
return (
<motion.div className={cn('relative', className)} {...props}>
<div className="absolute -inset-24 -z-10">
@ -94,7 +95,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
variants={HeroTitleVariants}
initial="initial"
animate="animate"
className="text-center text-4xl font-bold leading-tight tracking-tight md:text-[48px] lg:text-[64px]"
className="text-center text-4xl font-bold leading-tight tracking-tight lg:text-[64px]"
>
Document signing,
<span className="block" /> finally open source.
@ -106,22 +107,22 @@ export const Hero = ({ className, ...props }: HeroProps) => {
animate="animate"
className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4"
>
<Link href="https://app.documenso.com/signup?utm_source=marketing-hero">
<Button
type="button"
variant="outline"
className="rounded-full bg-transparent backdrop-blur-sm"
>
Try our Free Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
No Credit Card required
</span>
</Button>
</Link>
<Button
type="button"
variant="outline"
className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
>
Get the Early Adopters Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
$30/mo. forever!
</span>
</Button>
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
<LuGithub className="mr-2 h-5 w-5" />
Star on GitHub
Star on Github
</Button>
</Link>
</motion.div>
@ -168,11 +169,74 @@ export const Hero = ({ className, ...props }: HeroProps) => {
<motion.div
className="mt-12"
variants={HeroCarouselVariants}
variants={{
initial: {
scale: 0.2,
opacity: 0,
},
animate: {
scale: 1,
opacity: 1,
transition: {
ease: 'easeInOut',
delay: 0.5,
duration: 0.8,
},
},
}}
initial="initial"
animate="animate"
>
<Carousel />
<Widget className="mt-12">
<strong>Documenso Supporter Pledge</strong>
<p className="w-full max-w-[70ch]">
Our mission is to create an open signing infrastructure that empowers the world,
enabling businesses to embrace openness, cooperation, and transparency. We believe
that signing, as a fundamental act, should embody these values. By offering an
open-source signing solution, we aim to make document signing accessible, transparent,
and trustworthy.
</p>
<p className="w-full max-w-[70ch]">
Through our platform, called Documenso, we strive to earn your trust by allowing
self-hosting and providing complete visibility into its inner workings. We value
inclusivity and foster an environment where diverse perspectives and contributions are
welcomed, even though we may not implement them all.
</p>
<p className="w-full max-w-[70ch]">
At Documenso, we envision a web-enabled future for business and contracts, and we are
committed to being the leading provider of open signing infrastructure. By combining
exceptional product design with open-source principles, we aim to deliver a robust and
well-designed application that exceeds your expectations.
</p>
<p className="w-full max-w-[70ch]">
We understand that exceptional products are born from exceptional communities, and we
invite you to join our open-source community. Your contributions, whether technical or
non-technical, will help shape the future of signing. Together, we can create a better
future for everyone.
</p>
<p className="w-full max-w-[70ch]">
Today we invite you to join us on this journey: By signing this mission statement you
signal your support of Documenso's mission{' '}
<span className="bg-primary text-black">
(in a non-legally binding, but heartfelt way)
</span>{' '}
and lock in the early supporter plan for forever, including everything we build this
year.
</p>
<div className="flex h-24 items-center">
<p className={cn('text-5xl [font-family:var(--font-caveat)]')}>Timur & Lucas</p>
</div>
<div>
<strong>Timur Ercan & Lucas Smith</strong>
<p className="mt-1">Co-Founders, Documenso</p>
</div>
</Widget>
</motion.div>
</div>
</motion.div>

View File

@ -47,13 +47,9 @@ export const MENU_NAVIGATION_LINKS = [
text: 'Privacy',
},
{
href: 'https://app.documenso.com/signin?utm_source=marketing-header',
href: 'https://app.documenso.com/signin',
text: 'Sign in',
},
{
href: 'https://app.documenso.com/signup?utm_source=marketing-header',
text: 'Sign up',
},
];
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {

View File

@ -1,4 +1,4 @@
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import Image from 'next/image';

View File

@ -23,7 +23,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
return (
<div className={cn('', className)} {...props}>
<div className="bg-background sticky top-32 flex items-center justify-end gap-x-6 shadow-[-1px_-5px_2px_6px_hsl(var(--background))] md:top-[7.5rem] lg:static lg:justify-center">
<div className="flex items-center justify-center gap-x-6">
<AnimatePresence>
<motion.button
key="MONTHLY"
@ -40,7 +40,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
{period === 'MONTHLY' && (
<motion.div
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
className="bg-foreground lg:bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
/>
)}
</motion.button>
@ -58,12 +58,12 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
>
Yearly
<div className="bg-muted text-foreground block rounded-full px-2 py-0.5 text-xs">
Save $60 or $100
Save $60
</div>
{period === 'YEARLY' && (
<motion.div
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
className="bg-foreground lg:bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
/>
)}
</motion.button>
@ -75,7 +75,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
data-plan="free"
className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg"
>
<p className="text-foreground text-4xl font-medium">Free</p>
<p className="text-foreground text-4xl font-medium">Free Plan</p>
<p className="text-primary mt-2.5 text-xl font-medium">$0</p>
<p className="text-foreground mt-4 max-w-[30ch] text-center">
@ -83,11 +83,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p>
<Button className="rounded-full text-base" asChild>
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-free-plan`}
target="_blank"
className="mt-6"
>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank" className="mt-6">
Signup Now
</Link>
</Button>
@ -102,10 +98,10 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</div>
<div
data-plan="individual"
className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border-2 px-8 py-12 shadow-[0px_0px_0px_4px_#E3E3E380]"
data-plan="community"
className="border-primary bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border-2 px-8 py-12 shadow-[0px_0px_0px_4px_#E3E3E380]"
>
<p className="text-foreground text-4xl font-medium">Individual</p>
<p className="text-foreground text-4xl font-medium">Early Adopters</p>
<div className="text-primary mt-2.5 text-xl font-medium">
<AnimatePresence mode="wait">
{period === 'MONTHLY' && <motion.div layoutId="pricing">$30</motion.div>}
@ -114,59 +110,66 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</div>
<p className="text-foreground mt-4 max-w-[30ch] text-center">
Everything you need for a great signing experience.
For fast-growing companies that aim to scale across multiple teams.
</p>
<Button className="mt-6 rounded-full text-base" asChild>
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-individual-plan`}
target="_blank"
>
Signup Now
</Link>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}>Signup Now</Link>
</Button>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="text-foreground py-4">Unlimited Documents per Month</p>
<p className="text-foreground py-4">API Accesss</p>
<p className="text-foreground py-4">Email and Discord Support</p>
<p className="text-foreground py-4">Premium Profile Name</p>
<p className="text-foreground py-4 font-medium">
{' '}
<a href="https://documenso.com/blog/early-adopters" target="_blank" rel="noreferrer">
The Early Adopter Deal:
</a>
</p>
<p className="text-foreground py-4">Join the movement</p>
<p className="text-foreground py-4">Simple signing solution</p>
<p className="text-foreground py-4">Email, Discord and Slack assistance</p>
<p className="text-foreground py-4">
<strong>
{' '}
<a
href="https://documenso.com/blog/early-adopters"
target="_blank"
rel="noreferrer"
>
Includes all upcoming features
</a>
</strong>
</p>
<p className="text-foreground py-4">Fixed, straightforward pricing</p>
</div>
<div className="flex-1" />
</div>
<div
data-plan="teams"
className="border-primary bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg"
data-plan="enterprise"
className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg"
>
<p className="text-foreground text-4xl font-medium">Teams</p>
<div className="text-primary mt-2.5 text-xl font-medium">
<AnimatePresence mode="wait">
{period === 'MONTHLY' && <motion.div layoutId="pricingTeams">$50</motion.div>}
{period === 'YEARLY' && <motion.div layoutId="pricingTeams">$500</motion.div>}
</AnimatePresence>
</div>
<p className="text-foreground text-4xl font-medium">Enterprise</p>
<p className="text-primary mt-2.5 text-xl font-medium">Pricing on request</p>
<p className="text-foreground mt-4 max-w-[30ch] text-center">
For companies looking to scale across multiple teams.
For large organizations that need extra flexibility and control.
</p>
<Button className="mt-6 rounded-full text-base" asChild>
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-teams-plan`}
target="_blank"
>
Signup Now
</Link>
</Button>
<Link
href="https://dub.sh/enterprise"
target="_blank"
className="mt-6"
onClick={() => event('enterprise-contact')}
>
<Button className="rounded-full text-base">Contact Us</Button>
</Link>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="text-foreground py-4">Unlimited Documents per Month</p>
<p className="text-foreground py-4">API Accesss</p>
<p className="text-foreground py-4">Email and Discord Support</p>
<p className="text-foreground py-4 font-medium">Team Inbox</p>
<p className="text-foreground py-4">5 Users Included</p>
<p className="text-foreground py-4">Add More Users for $10/ mo.</p>
<p className="text-foreground py-4 font-medium">Everything in Early Adopters, plus:</p>
<p className="text-foreground py-4">Custom Subdomain</p>
<p className="text-foreground py-4">Compliance Check</p>
<p className="text-foreground py-4">Guaranteed Uptime</p>
<p className="text-foreground py-4">Reporting & Analysis</p>
<p className="text-foreground py-4">24/7 Support</p>
</div>
</div>
</div>

View File

@ -1,4 +1,4 @@
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import Image from 'next/image';
@ -51,7 +51,7 @@ export const ShareConnectPaidWidgetBento = ({
<Card className="col-span-2 lg:col-span-1" spotlight>
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="text-foreground/80 leading-relaxed">
<strong className="block">Connections</strong>
<strong className="block">Connections (Soon).</strong>
Create connections and automations with Zapier and more to integrate with your
favorite tools.
</p>
@ -70,7 +70,7 @@ export const ShareConnectPaidWidgetBento = ({
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="text-foreground/80 leading-relaxed">
<strong className="block">Get paid (Soon).</strong>
Integrated payments with Stripe so you dont have to worry about getting paid.
Integrated payments with stripe so you dont have to worry about getting paid.
</p>
<div className="flex items-center justify-center p-8">

View File

@ -55,7 +55,7 @@ export const SinglePlayerModeSuccess = ({
<SigningCard3D
className="mt-8"
name={document.Recipient[0].name || document.Recipient[0].email}
name={document.Recipient.name || document.Recipient.email}
signature={signatures.at(0)}
signingCelebrationImage={signingCelebration}
/>
@ -65,7 +65,7 @@ export const SinglePlayerModeSuccess = ({
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
<DocumentShareButton
documentId={document.id}
token={document.Recipient[0].token}
token={document.Recipient.token}
className="flex-1 bg-transparent backdrop-blur-sm"
/>
@ -86,7 +86,7 @@ export const SinglePlayerModeSuccess = ({
<p className="text-muted-foreground/60 mt-16 text-center text-sm">
Create a{' '}
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
target="_blank"
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
>

View File

@ -1,29 +0,0 @@
import React from 'react';
import { cn } from '@documenso/ui/lib/utils';
type SlideProps = {
selected: boolean;
index: number;
onClick: () => void;
label: string;
};
export const Slide: React.FC<SlideProps> = (props) => {
const { selected, label, onClick } = props;
return (
<button
onClick={onClick}
type="button"
className={cn(
'text-muted-foreground dark:text-muted-foreground/60 border-b-2 border-transparent py-4',
{
'border-primary text-foreground dark:text-muted-foreground border-b-2': selected,
},
)}
>
{label}
</button>
);
};

View File

@ -1,21 +0,0 @@
// https://github.com/documenso/documenso/pull/1044/files#r1538258462
import { Suspense } from 'react';
import { StatusWidget } from './status-widget';
export function StatusWidgetContainer() {
return (
<Suspense fallback={<StatusWidgetFallback />}>
<StatusWidget slug="documenso-status" />
</Suspense>
);
}
function StatusWidgetFallback() {
return (
<div className="border-border inline-flex max-w-fit items-center justify-between space-x-2 rounded-md border border-gray-200 px-2 py-2 pr-3 text-sm">
<span className="bg-muted h-2 w-36 animate-pulse rounded-md" />
<span className="bg-muted relative inline-flex h-2 w-2 rounded-full" />
</div>
);
}

View File

@ -1,73 +0,0 @@
import { memo, use } from 'react';
import { type Status, getStatus } from '@openstatus/react';
import { cn } from '@documenso/ui/lib/utils';
const getStatusLevel = (level: Status) => {
return {
operational: {
label: 'Operational',
color: 'bg-green-500',
color2: 'bg-green-400',
},
degraded_performance: {
label: 'Degraded Performance',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
partial_outage: {
label: 'Partial Outage',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
major_outage: {
label: 'Major Outage',
color: 'bg-red-500',
color2: 'bg-red-400',
},
unknown: {
label: 'Unknown',
color: 'bg-gray-500',
color2: 'bg-gray-400',
},
incident: {
label: 'Incident',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
under_maintenance: {
label: 'Under Maintenance',
color: 'bg-gray-500',
color2: 'bg-gray-400',
},
}[level];
};
export const StatusWidget = memo(function StatusWidget({ slug }: { slug: string }) {
const { status } = use(getStatus(slug));
const level = getStatusLevel(status);
return (
<a
className="border-border inline-flex max-w-fit items-center justify-between gap-2 space-x-2 rounded-md border border-gray-200 px-3 py-1 text-sm"
href="https://status.documenso.com"
target="_blank"
rel="noreferrer"
>
<div>
<p className="text-sm">{level.label}</p>
</div>
<span className="relative ml-auto flex h-1.5 w-1.5">
<span
className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
level.color2,
)}
/>
<span className={cn('relative inline-flex h-1.5 w-1.5 rounded-full', level.color)} />
</span>
</a>
);
});

View File

@ -0,0 +1,424 @@
'use client';
import type { HTMLAttributes, KeyboardEvent } from 'react';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { env } from 'next-runtime-env';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
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
.object({
email: z.string().email({ message: 'Please enter a valid email address.' }),
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
})
.and(
z.union([
z.object({
signatureDataUrl: z.string().min(1),
signatureText: z.null().or(z.string().max(0)),
}),
z.object({
signatureDataUrl: z.null().or(z.string().max(0)),
signatureText: z.string().trim().min(1),
}),
]),
);
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 [showSigningDialog, setShowSigningDialog] = useState(false);
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
const {
control,
register,
handleSubmit,
setValue,
trigger,
watch,
formState: { errors, isSubmitting, isValid },
} = useForm<TWidgetFormSchema>({
mode: 'onChange',
defaultValues: {
email: '',
name: '',
signatureDataUrl: null,
signatureText: '',
},
resolver: zodResolver(ZWidgetFormSchema),
});
const signatureDataUrl = watch('signatureDataUrl');
const signatureText = watch('signatureText');
const stepsRemaining = useMemo(() => {
if (step === STEP.NAME) {
return 2;
}
if (step === STEP.EMAIL) {
return 3;
}
return 1;
}, [step]);
const onNextStepClick = () => {
if (step === STEP.EMAIL) {
setStep(STEP.NAME);
setTimeout(() => {
document.querySelector<HTMLElement>('#name')?.focus();
}, 0);
}
if (step === STEP.NAME) {
setStep(STEP.SIGN);
setTimeout(() => {
document.querySelector<HTMLElement>('#signatureText')?.focus();
}, 0);
}
};
const onEnterPress = (callback: () => void) => {
return (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
callback();
}
};
};
const onSignatureConfirmClick = () => {
setValue('signatureDataUrl', draftSignatureDataUrl);
setValue('signatureText', '');
void trigger('signatureDataUrl');
setShowSigningDialog(false);
};
const onFormSubmit = async ({
email,
name,
signatureDataUrl,
signatureText,
}: TWidgetFormSchema) => {
try {
const delay = new Promise<void>((resolve) => {
setTimeout(resolve, 1000);
});
const planId = env('NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID');
if (!planId) {
throw new Error('No plan ID found.');
}
const claimPlanInput = signatureDataUrl
? {
name,
email,
planId,
signatureDataUrl: signatureDataUrl,
signatureText: null,
}
: {
name,
email,
planId,
signatureDataUrl: null,
signatureText: signatureText ?? '',
};
const [result] = await Promise.all([claimPlan(claimPlanInput), delay]);
event('claim-plan-widget');
window.location.href = result;
} catch (error) {
event('claim-plan-failed');
toast({
title: 'Something went wrong',
description: error instanceof Error ? error.message : 'Please try again later.',
variant: 'destructive',
});
}
};
return (
<>
<Card
className={cn('mx-auto w-full max-w-4xl rounded-3xl before:rounded-3xl', className)}
gradient
{...props}
>
<div className="grid grid-cols-12 gap-y-8 overflow-hidden p-2 lg:gap-x-8">
<div className="text-muted-foreground col-span-12 flex flex-col gap-y-4 p-4 text-xs leading-relaxed lg:col-span-7">
{children}
</div>
<form
className="bg-foreground/5 col-span-12 flex flex-col rounded-2xl p-6 lg:col-span-5"
onSubmit={handleSubmit(onFormSubmit)}
>
<h3 className="text-2xl font-semibold">Sign up for the early adopters plan</h3>
<p className="text-muted-foreground mt-2 text-xs">
with Timur Ercan & Lucas Smith from Documenso
</p>
<hr className="mb-6 mt-4" />
<AnimatePresence>
<motion.div key="email">
<label htmlFor="email" className="text-foreground text-lg font-semibold lg:text-xl">
Whats your email?
</label>
<Controller
control={control}
name="email"
render={({ field }) => (
<div className="relative mt-2">
<Input
id="email"
type="email"
placeholder=""
className="bg-background w-full pr-16"
disabled={isSubmitting}
onKeyDown={(e) =>
field.value !== '' &&
!errors.email?.message &&
onEnterPress(onNextStepClick)(e)
}
{...field}
/>
<div className="absolute inset-y-0 right-0 p-1.5">
<Button
type="button"
className="bg-primary h-full w-14 rounded"
disabled={!field.value || !!errors.email?.message}
onClick={() => step === STEP.EMAIL && onNextStepClick()}
>
Next
</Button>
</div>
</div>
)}
/>
<FormErrorMessage error={errors.email} className="mt-1" />
</motion.div>
{(step === STEP.NAME || step === STEP.SIGN) && (
<motion.div
key="name"
className="mt-4"
animate={{
opacity: 1,
transform: 'translateX(0)',
}}
initial={{
opacity: 0,
transform: 'translateX(-25%)',
}}
exit={{
opacity: 0,
transform: 'translateX(25%)',
}}
>
<label
htmlFor="name"
className="text-foreground text-lg font-semibold lg:text-xl"
>
and your name?
</label>
<Controller
control={control}
name="name"
render={({ field }) => (
<div className="relative mt-2">
<Input
id="name"
type="text"
placeholder=""
className="bg-background w-full pr-16"
disabled={isSubmitting}
onKeyDown={(e) =>
field.value !== '' &&
!errors.name?.message &&
onEnterPress(onNextStepClick)(e)
}
{...field}
/>
<div className="absolute inset-y-0 right-0 p-1.5">
<Button
type="button"
className="bg-primary h-full w-14 rounded"
disabled={!field.value || !!errors.name?.message}
onClick={() => onNextStepClick()}
>
Next
</Button>
</div>
</div>
)}
/>
<FormErrorMessage error={errors.name} className="mt-1" />
</motion.div>
)}
</AnimatePresence>
<div className="mt-12 flex-1" />
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-xs">
{isValid ? 'Ready for Signing' : `${stepsRemaining} step(s) until signed`}
</p>
<p className="text-muted-foreground block text-xs md:hidden">Minimise contract</p>
</div>
<div className="bg-background relative mt-2.5 h-[2px] w-full">
<div
className={cn('bg-primary/60 absolute inset-y-0 left-0 duration-200', {
'w-1/3': stepsRemaining === 3,
'w-2/3': stepsRemaining === 2,
'w-11/12': stepsRemaining === 1,
'w-full': isValid,
})}
/>
</div>
<Card id="signature" className="mt-4" degrees={-140} gradient>
<CardContent
role="button"
className="relative cursor-pointer pt-6"
onClick={() => setShowSigningDialog(true)}
>
<div className="flex h-28 items-center justify-center pb-6">
{!signatureText && signatureDataUrl && (
<img
src={signatureDataUrl}
alt="user signature"
className="h-full dark:invert"
/>
)}
{signatureText && (
<p
className={cn(
'text-foreground text-4xl font-semibold [font-family:var(--font-caveat)]',
)}
>
{signatureText}
</p>
)}
</div>
<div
className="absolute inset-x-0 bottom-0 flex cursor-auto items-center justify-between px-4 pb-2"
onClick={(e) => e.stopPropagation()}
>
<Input
id="signatureText"
className="text-foreground placeholder:text-muted-foreground border-none p-0 text-sm focus-visible:ring-0"
placeholder="Draw or type name here"
disabled={isSubmitting}
{...register('signatureText', {
onChange: (e) => {
if (e.target.value !== '') {
setValue('signatureDataUrl', null);
}
},
})}
/>
<Button
type="submit"
className="disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted h-8"
disabled={!isValid || isSubmitting}
>
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
Sign
</Button>
</div>
</CardContent>
</Card>
</form>
</div>
</Card>
<Dialog open={showSigningDialog} onOpenChange={setShowSigningDialog}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Add your signature</DialogTitle>
</DialogHeader>
<DialogDescription>
By signing you signal your support of Documenso's mission in a <br></br>
<strong>non-legally binding, but heartfelt way</strong>. <br></br>
<br></br>You also unlock the option to purchase the early supporter plan including
everything we build this year for fixed price.
</DialogDescription>
<SignaturePad
disabled={isSubmitting}
className="aspect-video w-full rounded-md border"
defaultValue={signatureDataUrl || ''}
onChange={setDraftSignatureDataUrl}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowSigningDialog(false)}>
Cancel
</Button>
<Button onClick={() => onSignatureConfirmClick()}>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -1,5 +1,5 @@
import { AnimatePresence, motion } from 'framer-motion';
import type { FieldError } from 'react-hook-form';
import { FieldError } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils';

View File

@ -1,4 +1,4 @@
import type { SVGAttributes } from 'react';
import { SVGAttributes } from 'react';
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;

View File

@ -13,7 +13,6 @@ import { updateFile } from '@documenso/lib/universal/upload/update-file';
import { prisma } from '@documenso/prisma';
import {
DocumentDataType,
DocumentSource,
DocumentStatus,
FieldType,
ReadStatus,
@ -105,7 +104,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const document = await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED,
userId: user.id,

View File

@ -3,7 +3,7 @@
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ThemeProviderProps } from 'next-themes/dist/types';
import { ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;

View File

@ -2,7 +2,6 @@
const fs = require('fs');
const path = require('path');
const { version } = require('./package.json');
const { withAxiom } = require('next-axiom');
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
@ -18,16 +17,11 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
);
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
);
/** @type {import('next').NextConfig} */
const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'),
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
serverActions: {
bodySizeLimit: '50mb',
},
@ -46,7 +40,6 @@ const config = {
APP_VERSION: version,
NEXT_PUBLIC_PROJECT: 'web',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
},
modularizeImports: {
'lucide-react': {
@ -97,4 +90,4 @@ const config = {
},
};
module.exports = withAxiom(config);
module.exports = config;

View File

@ -19,25 +19,20 @@
"@documenso/ee": "*",
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"@tanstack/react-query": "^4.29.5",
"cookie-es": "^1.0.0",
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
"input-otp": "^1.2.4",
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "14.0.3",
"next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-plausible": "^3.10.1",
"next-themes": "^0.2.1",
"papaparse": "^5.4.1",
"perfect-freehand": "^1.2.0",
"posthog-js": "^1.75.3",
"posthog-node": "^3.1.1",
@ -48,24 +43,20 @@
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
"remeda": "^1.27.1",
"sharp": "^0.33.1",
"sharp": "0.33.1",
"ts-pattern": "^5.0.5",
"typescript": "5.2.2",
"ua-parser-js": "^1.0.37",
"uqr": "^0.1.2",
"zod": "^3.22.4"
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@simplewebauthn/types": "^9.0.1",
"@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/papaparse": "^5.3.14",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7",
"@types/ua-parser-js": "^0.7.39",
"typescript": "5.2.2"
"@types/ua-parser-js": "^0.7.39"
},
"overrides": {
"next-auth": {

View File

@ -12,9 +12,5 @@ declare namespace NodeJS {
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
NEXT_PRIVATE_OIDC_WELL_KNOWN: string;
NEXT_PRIVATE_OIDC_CLIENT_ID: string;
NEXT_PRIVATE_OIDC_CLIENT_SECRET: string;
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,73 +0,0 @@
'use client';
import Link from 'next/link';
import type { Recipient } from '@documenso/prisma/client';
import { type Document, SigningStatus } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminActionsProps = {
className?: string;
document: Document;
recipients: Recipient[];
};
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
const { toast } = useToast();
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
trpc.admin.resealDocument.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Document resealed',
});
},
onError: () => {
toast({
title: 'Error',
description: 'Failed to reseal document',
variant: 'destructive',
});
},
});
return (
<div className={cn('flex gap-x-4', className)}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
loading={isResealDocumentLoading}
disabled={recipients.some(
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
)}
onClick={() => resealDocument({ id: document.id })}
>
Reseal document
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[40ch]">
Attempts sealing the document again, useful for after a code change has occurred to
resolve an erroneous document.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="outline" asChild>
<Link href={`/admin/users/${document.userId}`}>Go to owner</Link>
</Button>
</div>
);
};

View File

@ -1,91 +0,0 @@
import { DateTime } from 'luxon';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import { Badge } from '@documenso/ui/primitives/badge';
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
import { AdminActions } from './admin-actions';
import { RecipientItem } from './recipient-item';
import { SuperDeleteDocumentDialog } from './super-delete-document-dialog';
type AdminDocumentDetailsPageProps = {
params: {
id: string;
};
};
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
const document = await getEntireDocument({ id: Number(params.id) });
return (
<div>
<div className="flex items-start justify-between">
<div className="flex items-center gap-x-4">
<h1 className="text-2xl font-semibold">{document.title}</h1>
<DocumentStatus status={document.status} />
</div>
{document.deletedAt && (
<Badge size="large" variant="destructive">
Deleted
</Badge>
)}
</div>
<div className="text-muted-foreground mt-4 text-sm">
<div>
Created on: <LocaleDate date={document.createdAt} format={DateTime.DATETIME_MED} />
</div>
<div>
Last updated at: <LocaleDate date={document.updatedAt} format={DateTime.DATETIME_MED} />
</div>
</div>
<hr className="my-4" />
<h2 className="text-lg font-semibold">Admin Actions</h2>
<AdminActions className="mt-2" document={document} recipients={document.Recipient} />
<hr className="my-4" />
<h2 className="text-lg font-semibold">Recipients</h2>
<div className="mt-4">
<Accordion type="multiple" className="space-y-4">
{document.Recipient.map((recipient) => (
<AccordionItem
key={recipient.id}
value={recipient.id.toString()}
className="rounded-lg border"
>
<AccordionTrigger className="px-4">
<div className="flex items-center gap-x-4">
<h4 className="font-semibold">{recipient.name}</h4>
<Badge size="small" variant="neutral">
{recipient.email}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="border-t px-4 pt-4">
<RecipientItem recipient={recipient} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
<hr className="my-4" />
{document && <SuperDeleteDocumentDialog document={document} />}
</div>
);
}

View File

@ -1,182 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import {
type Field,
type Recipient,
type Signature,
SigningStatus,
} 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 {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZAdminUpdateRecipientFormSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
type TAdminUpdateRecipientFormSchema = z.infer<typeof ZAdminUpdateRecipientFormSchema>;
export type RecipientItemProps = {
recipient: Recipient & {
Field: Array<
Field & {
Signature: Signature | null;
}
>;
};
};
export const RecipientItem = ({ recipient }: RecipientItemProps) => {
const { toast } = useToast();
const router = useRouter();
const form = useForm<TAdminUpdateRecipientFormSchema>({
defaultValues: {
name: recipient.name,
email: recipient.email,
},
});
const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => {
try {
await updateRecipient({
id: recipient.id,
name,
email,
});
toast({
title: 'Recipient updated',
description: 'The recipient has been updated successfully',
});
router.refresh();
} catch (error) {
toast({
title: 'Failed to update recipient',
description: error.message,
variant: 'destructive',
});
}
};
return (
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onUpdateRecipientFormSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-4"
disabled={
form.formState.isSubmitting || recipient.signingStatus === SigningStatus.SIGNED
}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button type="submit" loading={form.formState.isSubmitting}>
Update Recipient
</Button>
</div>
</fieldset>
</form>
</Form>
<hr className="my-4" />
<h2 className="mb-4 text-lg font-semibold">Fields</h2>
<DataTable
data={recipient.Field}
columns={[
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
{
header: 'Type',
accessorKey: 'type',
cell: ({ row }) => <div>{row.original.type}</div>,
},
{
header: 'Inserted',
accessorKey: 'inserted',
cell: ({ row }) => <div>{row.original.inserted ? 'True' : 'False'}</div>,
},
{
header: 'Value',
accessorKey: 'customText',
cell: ({ row }) => <div>{row.original.customText}</div>,
},
{
header: 'Signature',
accessorKey: 'signature',
cell: ({ row }) => (
<div>
{row.original.Signature?.typedSignature && (
<span>{row.original.Signature.typedSignature}</span>
)}
{row.original.Signature?.signatureImageAsBase64 && (
<img
src={row.original.Signature.signatureImageAsBase64}
alt="Signature"
className="h-12 w-full dark:invert"
/>
)}
</div>
),
},
]}
/>
</div>
);
};

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