Compare commits
56 Commits
v1.6.0
...
v1.7.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| d599ab0630 | |||
| 9e714d607e | |||
| 81479b5b55 | |||
| 15efc6c36d | |||
| 9638dfbf37 | |||
| dfa89ffe44 | |||
| 7943ed5353 | |||
| cb50274450 | |||
| 04b92eac1d | |||
| 38a4b0f299 | |||
| 75c8772a02 | |||
| 0829311214 | |||
| 9223527b6f | |||
| 66fdc1d659 | |||
| 27066e2022 | |||
| 9178dbd3c1 | |||
| 2c9136498c | |||
| 7a1341eb74 | |||
| 06c0a50401 | |||
| 025e73e640 | |||
| 73800d1503 | |||
| 063ed966df | |||
| f568025a0b | |||
| ab8701526c | |||
| 20ec2dde3d | |||
| 3b8914da83 | |||
| 29910ab2a7 | |||
| ef3ecc33f1 | |||
| 0244f021ab | |||
| e5f73452b3 | |||
| c605877924 | |||
| e0065a8731 | |||
| f74265850b | |||
| 909c38f47e | |||
| 1beb434a72 | |||
| 5582f29bda | |||
| 7ed0a909eb | |||
| a9025b5d97 | |||
| 0c744a1123 | |||
| 0f86eed6ac | |||
| 4b485268ca | |||
| f31caaab08 | |||
| 71a8a3eaa6 | |||
| 0ff3844f18 | |||
| 3421515452 | |||
| 1028184cf2 | |||
| 277a870580 | |||
| a8febae87e | |||
| b366ab8736 | |||
| 994f6867f5 | |||
| c2374a9d65 | |||
| 7a1b9feee3 | |||
| ddc704518f | |||
| f647244e07 | |||
| f8349bb927 | |||
| d6ec3f252a |
@ -10,12 +10,19 @@ NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
||||
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
||||
|
||||
# [[AUTH OPTIONAL]]
|
||||
# Find documentation on setting up Google OAuth here:
|
||||
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
|
||||
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=""
|
||||
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
|
||||
# This can be used to still allow signups for OIDC connections
|
||||
# when signup is disabled via `NEXT_PUBLIC_DISABLE_SIGNUP`
|
||||
NEXT_PRIVATE_OIDC_ALLOW_SIGNUP=""
|
||||
NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
|
||||
|
||||
# [[URLS]]
|
||||
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||
|
||||
@ -11,4 +11,5 @@ module.exports = {
|
||||
rootDir: ['apps/*/'],
|
||||
},
|
||||
},
|
||||
ignorePatterns: ['lingui.config.ts', 'packages/lib/translations/**/*.js'],
|
||||
};
|
||||
|
||||
38
.github/workflows/translations-extract.yml
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
# Extract and compile translations for all PRs.
|
||||
|
||||
name: 'Extract and compile translations'
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
branches: ['main']
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
extract_translations:
|
||||
name: Extract and compile translations
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
|
||||
- uses: ./.github/actions/node-install
|
||||
|
||||
- name: Extract and compile translations
|
||||
run: |
|
||||
npm run translate:extract
|
||||
npm run translate:compile
|
||||
|
||||
- name: Check and commit any files created
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@documenso.com'
|
||||
git add packages/lib/translations
|
||||
git diff --staged --quiet --exit-code || (git commit -m "chore: extract translations" && git push)
|
||||
51
.github/workflows/translations-force-pull.yml
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
# This is similar to the "Pull Translations" workflow, but without the conditional check to allow us to
|
||||
# forcefully pull down translations from Crowdin and create a PR regardless if all the translations are fulfilled.
|
||||
#
|
||||
# Intended to be used when we manually update translations in Crowdin UI and want to pull those down when
|
||||
# they already exist.
|
||||
|
||||
name: 'Force pull translations'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pull_translations:
|
||||
name: Force pull translations
|
||||
runs-on: ubuntu-latest
|
||||
environment: Translations
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: ./.github/actions/node-install
|
||||
|
||||
- name: Pull translations from Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
export_only_approved: false
|
||||
localization_branch_name: chore/translations
|
||||
commit_message: 'chore: add translations'
|
||||
pull_request_title: 'chore: add translations'
|
||||
|
||||
env:
|
||||
# A classic GitHub Personal Access Token with the 'repo' scope selected (the user should have write access to the repository).
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
|
||||
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
8
.github/workflows/translations-pull.yml
vendored
@ -3,11 +3,10 @@
|
||||
name: 'Pull translations'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */2 * * *' # Every two hours.
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
# Cron disabled until i18n PR is landed.
|
||||
# schedule:
|
||||
# - cron: '0 */2 * * *' # Every two hours.
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
@ -17,6 +16,7 @@ jobs:
|
||||
pull_translations:
|
||||
name: Pull translations
|
||||
runs-on: ubuntu-latest
|
||||
environment: Translations
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
env:
|
||||
# A classic GitHub Personal Access Token with the 'repo' scope selected (the user should have write access to the repository).
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_CROWDIN_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
|
||||
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
58
.github/workflows/translations-upload.yml
vendored
Normal file
@ -0,0 +1,58 @@
|
||||
name: 'Extract and upload translations'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: ['main']
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
extract_translations:
|
||||
name: Extract and upload translations
|
||||
runs-on: ubuntu-latest
|
||||
environment: Translations
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
|
||||
- uses: ./.github/actions/node-install
|
||||
|
||||
- name: Extract and compile translations
|
||||
run: |
|
||||
npm run translate:extract
|
||||
npm run translate:compile
|
||||
|
||||
- name: Check and commit any files created
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@documenso.com'
|
||||
git add packages/lib/translations
|
||||
git diff --staged --quiet --exit-code || (git commit -m "chore: extract translations" && git push)
|
||||
|
||||
- name: Compile translations
|
||||
id: compile_translations
|
||||
run: npm run translate:compile -- -- --strict
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload missing translations
|
||||
if: ${{ steps.compile_translations.outcome == 'failure' }}
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: false
|
||||
localization_branch_name: chore/translations
|
||||
env:
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
2
.gitignore
vendored
@ -1,5 +1,7 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
packages/prisma/generated/types.ts
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
|
||||
@ -13,4 +13,9 @@ node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
|
||||
git add "$MONOREPO_ROOT/apps/web/public/"
|
||||
git add "$MONOREPO_ROOT/apps/marketing/public/"
|
||||
|
||||
echo "Extract and compile translations"
|
||||
npm run translate:extract
|
||||
npm run translate:compile
|
||||
git add "$MONOREPO_ROOT/packages/lib/translations/"
|
||||
|
||||
npx lint-staged
|
||||
|
||||
@ -4,6 +4,7 @@ public
|
||||
**/**/node_modules
|
||||
**/**/.next
|
||||
**/**/public
|
||||
packages/lib/translations/**/*.js
|
||||
|
||||
*.lock
|
||||
*.log
|
||||
|
||||
12
.vscode/settings.json
vendored
@ -5,12 +5,7 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"eslint.validate": [
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"javascript",
|
||||
"javascriptreact"
|
||||
],
|
||||
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||
"javascript.preferences.useAliasesForRenames": false,
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
@ -20,4 +15,7 @@
|
||||
"[prisma]": {
|
||||
"editor.defaultFormatter": "Prisma.prisma"
|
||||
},
|
||||
}
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
|
||||
@ -261,6 +261,7 @@ npm run prisma:migrate-deploy
|
||||
Finally, you can start it with:
|
||||
|
||||
```
|
||||
cd apps/web
|
||||
npm run start
|
||||
```
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"index": "Getting Started",
|
||||
"signing-certificate": "Signing Certificate",
|
||||
"how-to": "How To"
|
||||
}
|
||||
"how-to": "How To",
|
||||
"setting-up-oauth-providers": "Setting up OAuth Providers"
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Setting up OAuth Providers
|
||||
description: Learn how to set up OAuth providers for your own instance of Documenso.
|
||||
---
|
||||
|
||||
## Google OAuth (Gmail)
|
||||
|
||||
To use Google OAuth, you will need to create a Google Cloud Platform project and enable the Google Identity and Access Management (IAM) API. You will also need to create a new OAuth client ID and download the client secret.
|
||||
|
||||
### Create and configure a new OAuth client ID
|
||||
|
||||
1. Go to the [Google Cloud Platform Console](https://console.cloud.google.com/)
|
||||
2. From the projects list, select a project or create a new one
|
||||
3. If the APIs & services page isn't already open, open the console left side menu and select APIs & services
|
||||
4. On the left, click Credentials
|
||||
5. Click New Credentials, then select OAuth client ID
|
||||
6. When prompted to select an application type, select Web application
|
||||
7. Enter a name for your client ID, and click Create
|
||||
8. Click the download button to download the client secret
|
||||
9. Set the authorized javascript origins to `https://<documenso-domain>`
|
||||
10. Set the authorized redirect URIs to `https://<documenso-domain>/api/auth/callback/google`
|
||||
11. In the Documenso environment variables, set the following:
|
||||
|
||||
```
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=<your-client-id>
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=<your-client-secret>
|
||||
```
|
||||
|
||||
Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile.
|
||||
@ -37,7 +37,7 @@ To create a new webhook subscription, you need to provide the following informat
|
||||
|
||||
- Enter the webhook URL that will receive the event payload.
|
||||
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`.
|
||||
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Signature` header of the request.
|
||||
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
|
||||
|
||||

|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"index": "Introduction",
|
||||
"support": "Support",
|
||||
"-- How To Use": {
|
||||
"type": "separator",
|
||||
"title": "How To Use"
|
||||
@ -13,6 +14,7 @@
|
||||
"type": "separator",
|
||||
"title": "Legal Overview"
|
||||
},
|
||||
"fair-use": "Fair Use Policy",
|
||||
"licenses": "Licenses",
|
||||
"compliance": "Compliance"
|
||||
}
|
||||
|
||||
@ -10,6 +10,8 @@ import { Callout } from 'nextra/components';
|
||||
signatures to ensure their authenticity, integrity, and confidentiality in the pharmaceutical, medical
|
||||
device, and other FDA-regulated industries.
|
||||
|
||||
> Read more about 21 CFR Part 11 with Documenso here: https://documen.so/21-CFR-Part-11
|
||||
|
||||
### Main Requirements
|
||||
|
||||
- [x] Strong Identity Checks for each Signature
|
||||
|
||||
34
apps/documentation/pages/users/fair-use.mdx
Normal file
@ -0,0 +1,34 @@
|
||||
---
|
||||
title: Fair Use Policy
|
||||
description: Learn about our fair use policy, which enables us to have unlimited plans.
|
||||
---
|
||||
|
||||
import { Callout } from 'nextra/components';
|
||||
|
||||
# Fair Use Policy
|
||||
|
||||
### Why
|
||||
|
||||
We offer our plans without any limits on volume because we want our users and customers to make the most of their accounts. Estimating volume is incredibly hard, especially for shorter intervals like a quarter. We are not interested in selling volume packages our customers end up not using. This is why the individual plan and the team plan do not include a limit on signing or API volume. If you are a customer of these [plans](https://documen.so/pricing), we ask you to abide by this fair use policy:
|
||||
|
||||
### Spirit of the Plan
|
||||
|
||||
> Use the limitless accounts as much as you like (they are meant to offer a lot) while respecting the spirit and intended scope of the account.
|
||||
|
||||
<Callout type="info">
|
||||
What happens if I violate this policy? We will ask you to upgrade to a fitting plan or custom
|
||||
pricing. We won’t block your account without reaching out. [Message
|
||||
us](mailto:support@documenso.com) for questions. It's probably fine, though.
|
||||
</Callout>
|
||||
|
||||
### DO
|
||||
|
||||
- Sign as many documents with the individual plan for your single business or organization you are part of
|
||||
- Use the API and Zapier to automate all your signing to sign as much as possible
|
||||
- Experiment with the plans and integrations, testing what you want to build: When in doubt, do it. Especially if you are just starting.
|
||||
|
||||
### DON'T
|
||||
|
||||
- Use the individual account's API to power a platform
|
||||
- Run a huge company, signing thousands of documents per day on a two-user team plan using the API
|
||||
- Let this policy make you overthink. If you are a paying customer, we want you to win, and it's probably fine
|
||||
@ -3,7 +3,7 @@ title: Create Your Account
|
||||
description: Learn how to create an account on Documenso.
|
||||
---
|
||||
|
||||
import { Steps } from 'nextra/components';
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# Create Your Account
|
||||
|
||||
@ -14,6 +14,8 @@ The first step to start using Documenso is to pick a plan and create an account.
|
||||
|
||||
Explore each plan's features and choose the one that best suits your needs. The [pricing page](https://documen.so/pricing) has more information about the plans.
|
||||
|
||||
<Callout>All plans are subject to our [Fair Use Policy](/users/fair-use).</Callout>
|
||||
|
||||
### Create an account
|
||||
|
||||
If you are unsure which plan to choose, you can start with the free plan and upgrade later.
|
||||
|
||||
38
apps/documentation/pages/users/support.mdx
Normal file
@ -0,0 +1,38 @@
|
||||
---
|
||||
title: Support
|
||||
description: Learn what types of support we offer.
|
||||
---
|
||||
|
||||
# Support
|
||||
|
||||
## Community Support
|
||||
|
||||
If you are a developer or free user, you can reach out to the community or raise an issue:
|
||||
|
||||
### [Create Github Issues](https://github.com/documenso/documenso/issues)
|
||||
|
||||
The community and the core team address GitHub issues. Be sure to check if a similar issue already exists. Please note that while we want to address everything immediately, we must prioritize.
|
||||
|
||||
### [Join our Discord](https://documen.so/discord)
|
||||
|
||||
You can ask for help in the [community help channel](https://discord.com/channels/1132216843537485854/1133419426524430376).
|
||||
|
||||
## Paid Account Support
|
||||
|
||||
### Email: support@documenso.com
|
||||
|
||||
If you are paying customers facing issues, email our customer support, especially in urgent cases.
|
||||
|
||||
### Private Discord channel
|
||||
|
||||
If you prefer Discord, we can invite you to a private channel. Message support to make this happen.
|
||||
|
||||
## Enterprise Support
|
||||
|
||||
### Email: support@documenso.com
|
||||
|
||||
If you are paying customers facing issues, email our customer support, especially in urgent cases.
|
||||
|
||||
### Slack
|
||||
|
||||
If your team is on Slack, we can create a private workspace to support you more closely.
|
||||
@ -11,14 +11,7 @@ tags:
|
||||
- Compliance
|
||||
---
|
||||
|
||||
<video
|
||||
id="vid"
|
||||
width="100%"
|
||||
src="/blog/vial.webm"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
></video>
|
||||
<video id="vid" width="100%" src="/blog/vial.webm" autoPlay loop muted></video>
|
||||
<figcaption className="text-center">
|
||||
Vial.com uses Documenso for 21 CFR Part 11 compliant signing.
|
||||
</figcaption>
|
||||
@ -26,42 +19,40 @@ tags:
|
||||
> TLDR; We launched Vial.com on Documenso and are open for 21 CFR Part 11 business.
|
||||
|
||||
# What is 21 CFR
|
||||
You have never heard of 21 CFR Part 11? You are in good company since most people haven't. If you have, you probably work in an industry regulated by the U.S. Food and Drug Administration (FDA). Title 21 of the Code of Federal Regulations (CFR) is dedicated to detailing FDA-regulated business, and sub-part 11 sets out guidelines for using electronic signatures in this highly regulated field. Hence, 21 CFR Part 11 is highly relevant for regulated industries that aim to employ digital signatures. The guidelines set out in 21 CFR Part 11 aim to provide trustworthy, reliable, and equivalent to paper records and handwritten signatures. All Industries that fall under the FDA's regulation, e.g. pharmaceuticals, biotechnology, medical devices, and biologics, must comply with these rules when choosing or creating systems for electronic signatures.
|
||||
|
||||
You have never heard of 21 CFR Part 11? You are in good company since most people haven't. If you have, you probably work in an industry regulated by the U.S. Food and Drug Administration (FDA). Title 21 of the Code of Federal Regulations (CFR) is dedicated to detailing FDA-regulated business, and sub-part 11 sets out guidelines for using electronic signatures in this highly regulated field. Hence, 21 CFR Part 11 is highly relevant for regulated industries that aim to employ digital signatures. The guidelines set out in 21 CFR Part 11 aim to provide trustworthy, reliable, and equivalent to paper records and handwritten signatures. All Industries that fall under the FDA's regulation, e.g. pharmaceuticals, biotechnology, medical devices, and biologics, must comply with these rules when choosing or creating systems for electronic signatures.
|
||||
|
||||
Compliance with 21 CFR Part 11 is crucial for companies to use electronic records and signatures in their operations legally. It affects how companies manage documentation, conduct audits, and maintain regulatory submissions. Non-compliance can result in legal penalties, rejected submissions, and delays in product approvals, emphasizing the importance of adhering to these guidelines in FDA-regulated activities.
|
||||
|
||||
# Vial.com
|
||||
|
||||
Vial is a technology company on a mission to advance programs to market through computationally designed therapeutics and cost-effective clinical trials. It is imperative that Vial manages this process securely, effectively, and highly compliant. By leveraging it's modern platform, Vial aims to accelerate drug development and, ultimately, time to market for new therapies. You can learn more about them [here](https://vial.com/about-us).
|
||||
|
||||
[Together](https://documen.so/vial-documenso), Documenso and Vial set out to create the first open-source, 21 CFR Part 11 compliant signing solution. After iterating over the product together, Vial moved their operation from DocuSign, a known legacy signing provider, to a Documenso Enterprise plan. We are very happy to be able to support Vial’s mission by fulfilling our own: bringing open signing and all its innovation to where it's needed.
|
||||
|
||||
# 21 CFR Part 11 on Documenso Highlights
|
||||
|
||||
21 CFR Part 11 is a highly complex statute, and going into the all design rationales and the following implementation details, deserves its own article later. For now, I want to share a few notable highlights.
|
||||
|
||||
## The Full Experience
|
||||
|
||||
We implemented 21 CFR Part 11, keeping the main user experience of Documenso intact. Our 21 CFR module is not separate but natively integrated into all Documenso flows, thus not sacrificing usability for compliance. This also means most (if not all) advanced features we offer are usable in a compliant way. This prevents customers from being trapped in an anti-innovation bubble, not allowing access to new features for fear of non-compliance.
|
||||
|
||||
## Action Reauth Using Passkeys
|
||||
<video
|
||||
id="vid"
|
||||
width="100%"
|
||||
src="/blog/vial2.webm"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
controls
|
||||
></video>
|
||||
|
||||
<video id="vid" width="100%" src="/blog/vial2.webm" autoPlay loop muted controls></video>
|
||||
<figcaption className="text-center">
|
||||
Using passkeys (used here via fingerprint scanner) is the smoothest way to re-authenticate.
|
||||
</figcaption>
|
||||
|
||||
|
||||
One of the requirements affecting day-to-day life the most is the requirement to actually reauthenticate every signature placed on a document. While we can't change that, we can help make the reauthentication as painless as possible. To this end, we opted for passkeys. While Documenso supports passkeys to log in, they are also supported to authenticate signing on a per-signature level as part of the Documenso Enterprise Plan. The user still has to authenticate every signature but can now do so from the comfort of their passkey provider, be that 1Password, their browser, or any other provider.
|
||||
|
||||
## Direct Links
|
||||
|
||||
We recently launched [Direct Template Links](https://documen.so/direct-links), a new way to let people sign and fill out forms. Links can be completed anytime, creating a new document in the process. Direct Links are also 21 CFR part 11 compliant, using action reauthentication, audit log, and all other compliance requirements.
|
||||
|
||||
# Documenso Enterprise Plan
|
||||
|
||||
With the successful launch of Vial, we are now open for business. 21 CFR Part 11 compliance is part of the Documenso Enterprise plan, which includes all regulations we currently support and upcoming additions. While the pricing depends heavily on your needs and scale, we offer fixed-price plans for better predictability for both sides. In our experience, volume-based pricing is a legacy headache we want to avoid.
|
||||
|
||||
If you are FDA-regulated and looking for a modern signing solution, we are happy to discuss your requirements in detail. You can write us (hi@documenso.com) or contact [our enterprise team](https://documen.so/21cfr) at any time or stage.
|
||||
@ -70,4 +61,3 @@ If you have any questions or comments, please reach out on [Twitter / X](https:/
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
---
|
||||
title: Creating an Efficient Statement of Work Approval Process with Documenso
|
||||
description: Submitting statements of work can be a drag on morale and project efficiency. Let's look at how to create a modern, low-friction workflow for this.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-07-23
|
||||
tags:
|
||||
- Freelancer
|
||||
- Statement of Work
|
||||
- Productivity
|
||||
---
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/sov.webp"
|
||||
width="1400"
|
||||
height="884"
|
||||
alt="Working papers image"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">Fine-tune your process using custom role for everyone involved.</figcaption>
|
||||
</figure>
|
||||
|
||||
> TLDR; Statements of Work detail what needs to be done. Automate sending and approving them using Documenso and Zapier.
|
||||
|
||||
## What is a Statement of Work
|
||||
|
||||
A statement of work is a detailed document that outlines what needs to be done in a project. It covers the project's scope, objectives, and deliverables, laying out all the tasks, deadlines, and milestones. The statement of work also spells out who’s responsible for what, ensuring everyone’s on the same page. It’s a roadmap that keeps both clients and service providers aligned and ensures the project stays on track from start to finish.
|
||||
|
||||
In the context of freelance work, the statement of work is a document that outlines the details of a project between a freelancer and their client. It's a concrete work to be agreed upon and tracked after completion. The statement of work is created after the [proposal is accepted](https://documen.so/freelance-proposal) and the [contract signed](https://documen.so/freelance-contract).
|
||||
|
||||
## What does a good workflow look like?
|
||||
|
||||
### 1. Create the statement of work
|
||||
|
||||
The team at Zapier created a [excellent guide](https://zapier.com/blog/statement-of-work-template/), which goes into the statement of work. There is a short checklist:
|
||||
|
||||
- Project Context/ Current Scope
|
||||
- Objectives for this piece of work
|
||||
- Scope (tasks, activities, and limits)
|
||||
- Requirements (e.g., technical and regulatory)
|
||||
- Deliverables to be created
|
||||
- Roles and Responsibilities
|
||||
|
||||
### 2. Get approval from subject matter experts (optional)
|
||||
|
||||
Since a statements of work can be very technical, having professionals from either side approve it first can be sensible. This can avoid double or unnecessary work and minimize the chances of misunderstandings. If this makes sense, it depends heavily on the scale of the project and the level of insight of the professional providing it.
|
||||
|
||||
### 3. Let the client sign off
|
||||
|
||||
Having the client sign off on the concrete work is the central step of the statement of work workflow. Assuming the document’s content is correct, getting the go-ahead ensures everyone is aligned and clear on what should and will be worked on.
|
||||
|
||||
### 4. Inform other Stakeholders (optional)
|
||||
|
||||
Depending on the scale of the organizations working together, other people may need to be kept in the loop. This could be accounting on either side, project managers, or other interested parties.
|
||||
|
||||
## Fine-Tuning the Flow with Custom Roles
|
||||
|
||||
<figure>
|
||||
<MdxNextImage src="/blog/roles.webp" width="1400" height="884" alt="Documenso Roles UI" />
|
||||
<figcaption className="text-center">
|
||||
Let's take a look at what it would look like with Documenso.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
### 1. Creating the Document: Templates vs. Custom Document
|
||||
|
||||
[Creating a template](https://docs.documenso.com/users/templates) can make sense if you submit statements of work regularly. If you create a Documenso Template, you can add [dynamic text and number fields](https://docs.documenso.com/users/signing-documents/fields) to be filled out when using the template. Another approach to this is creating a template on a document service like Google Docs, filling out a new copy, and uploading the custom-created document to Documenso.
|
||||
|
||||
Different parts of this process can be automated using the [Zapier Documenso Integration](https://documen.so/zapier) as desired:
|
||||
|
||||
- Automatically sending out a template to be filled out and signed
|
||||
- Creating a document in Documenso from a newly created document in Google Docs
|
||||
- Triggering sending a document created from either template or automation
|
||||
|
||||
### 2. Approvals
|
||||
|
||||
Looping in subject matter experts can easily be done using the approver role. This role allows you to complete a document without blocking the signing. This means the client can sign a document, even if the approval is not yet in place, removing friction. However, having the approval denied will stop the document from being completed and let everyone know there are corrections to be made. A software version of this is possible, having the expert in a viewer role and marking the document as seen without the option to block.
|
||||
|
||||
### 3. Signers
|
||||
|
||||
Looping in the client is done by adding one or more signer roles. Signer roles require recipients to place at least one signature to fulfill their part in the flow.roles
|
||||
|
||||
### 4. BCC
|
||||
|
||||
You can add one or several BCC roles to inform interested parties, e.g., accounting or project managers. As the process finishes, BCC recipients receive a copy of the completed document (assuming it completes and is not blocked). Using BCC roles automates filling in everyone who is only interested in the outcome and wants to avoid involvement in the steps leading up to it.
|
||||
|
||||
### Conclusion
|
||||
|
||||
Streamlining your statement of work approval process with Documenso can significantly improve productivity and ease. Using templates, dynamic fields, and Zapier integrations, you can create a smooth, efficient workflow from start to finish. Adding roles for experts, signers, and BCC recipients tailors the process to fit your project's needs, ensuring everyone stays on the same page.
|
||||
|
||||
An efficient SOW process saves time and improves communication, letting you focus on delivering great work. We're excited to hear your thoughts and experiences—reach out on [Twitter / X](https://twitter.com/eltimuro) (DMs are open) or [Discord](https://documen.so/discord) if you have any questions, ideas, or comments.
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
@ -0,0 +1,599 @@
|
||||
---
|
||||
title: 'Enhancing Document Signing: Introducing 5 New Advanced Fields'
|
||||
description: "Explore Documenso's new advanced signing fields, including improved text fields, numbers, radio buttons, checkboxes, and dropdowns. Learn about the development challenges we overcame and how these additions provide greater flexibility for document signing."
|
||||
authorName: 'Catalin Pit'
|
||||
authorImage: '/blog/blog-author-catalin.webp'
|
||||
authorRole: 'I like to code and write'
|
||||
date: 2024-08-09
|
||||
tags:
|
||||
- Signing fields
|
||||
- Development
|
||||
---
|
||||
|
||||
Until recently, Documenso provided a set of 5 fields for document signing: signature, email, name, date, and a text field for additional information. While these fields covered the basic requirements for document signing, we recognized the need for more flexibility and variety.
|
||||
|
||||
As a result, we've decided to introduce several additional fields, such as:
|
||||
|
||||
- _(an improved)_ Text field
|
||||
- Number field
|
||||
- Radio field
|
||||
- Checkbox field
|
||||
- Dropdown/Select field
|
||||
|
||||
These new fields bring more flexibility and variety to Documenso. As the document owner, they allow you to gather more specific or extra information from the signers.
|
||||
|
||||
## New Fields Introduction
|
||||
|
||||
Let's take a closer look at each new field type.
|
||||
|
||||
### Text Field
|
||||
|
||||
While the text field was previously available, it could not be configured. It was a simple input box where signers could enter a single line of text.
|
||||
|
||||
The image illustrates the old text field in the document editor.
|
||||
|
||||

|
||||
|
||||
The revamped text field now offers a range of configuration options, allowing you to:
|
||||
|
||||
- Add a label, placeholder, default text, and character limit
|
||||
- Set the field as required or read-only
|
||||
|
||||

|
||||
|
||||
On the signing side, the field remained mostly the same visually. The only thing that changed is the functionality, which needs to take into consideration the validation rules. For example, if the field is required, the signer must enter a value to sign it. Or, if the field has a character limit, the value entered by the signer shouldn't exceed the limit.
|
||||
|
||||
The image below illustrates four different text fields with various configurations.
|
||||
|
||||

|
||||
|
||||
The first text field has no default value ("Add text") or configuration. You can sign the field by entering any text.
|
||||
|
||||

|
||||
|
||||
The second text field, "label-1"/"text-1", has the following configurations:
|
||||
|
||||
- Label
|
||||
- Placeholder
|
||||
- Default text
|
||||
- Character limit
|
||||
|
||||
Since there is a default value, the field auto-signs with that value. However, you can re-sign the field with a new value that doesn't exceed the character limit.
|
||||
|
||||

|
||||
|
||||
The third field, "label-2"/"text-2", has the same configurations as the second one, with one addition - the `required` option is checked. When the field is marked as `required`, you must sign it before completing the document.
|
||||
|
||||
Apart from that, it works like the second field.
|
||||
|
||||

|
||||
|
||||
The fourth field, "label-3"/"text-3", has the same configurations as the second one, with one addition—`read-only` is checked. That means the field auto-signs with the default value, and you cannot modify it.
|
||||
|
||||
#### Unsigned Fields
|
||||
|
||||
You can unsign a field to change the value and sign it again. The unsigned state of the field varies depending on its configuration:
|
||||
|
||||
- If the field has a label, it displays it instead of "Add text" when unsigned.
|
||||
- If the field has a default value, the default value will be shown when unsigned.
|
||||
- If the field has both a label and a default value, the label will take precedence and be displayed when unsigned.
|
||||
|
||||
The image below shows the unsigned state of the text fields.
|
||||
|
||||

|
||||
|
||||
The only exception is the fourth, read-only field, which cannot be unsigned or modified.
|
||||
|
||||
### Number Field
|
||||
|
||||
We also introduced a new "Number" field for inserting and signing documents with numeric values. This field helps collect quantities, measurements, and other data best represented as numbers.
|
||||
|
||||

|
||||
|
||||
The "Number" field offers a range of configuration options, which allows you to:
|
||||
|
||||
- Set a label, placeholder and default value
|
||||
- Specify the number format
|
||||
- Mark the field as _required_ or _read-only_
|
||||
- Specify minimum and maximum values
|
||||
|
||||
The Number field looks and works similarly to the Text field. The difference is that it accepts only numeric values and has 2 additional configurations: the number format and the minimum and maximum values.
|
||||
|
||||
### Radio Field
|
||||
|
||||
Radio buttons allow signers to select a single option from a pre-defined list the document owner sets.
|
||||
|
||||
Before sending the document for signing, you must add at least one radio option, which can contain a string or an empty value and can be checked or unchecked. However, it's important to note that only one option can be checked at a time.
|
||||
|
||||
When it comes to field configuration, you can mark the field as _required_ or _read-only_.
|
||||
|
||||

|
||||
|
||||
The image below shows what the signer sees after the document is sent for signing.
|
||||
|
||||

|
||||
|
||||
Note: The image is modified to display both the unsigned and signed states of the field.
|
||||
|
||||
Since the field has a preselected option (option `radio-val-2-checked`), it will automatically sign with that value and appear like the field marked with the number 1.
|
||||
|
||||
If the field is not read-only, the signer can:
|
||||
|
||||
- Unsign the field and choose another option by clicking on it.
|
||||
- Re-sign with the default value by refreshing the page when the field is unsigned.
|
||||
|
||||
However, if the field is marked as read-only, the signer cannot modify the preselected value.
|
||||
|
||||
### Dropdown/Select Field
|
||||
|
||||
We have also introduced a new "Dropdown/Select" field that allows signers to pick an option from a pre-defined list of choices. This field type is ideal for scenarios with limited valid options, such as selecting a country, state, or category.
|
||||
|
||||
When setting up a "Dropdown/Select" field, you can:
|
||||
|
||||
- Add multiple options
|
||||
- Mark the field as _required_ or _read-only_
|
||||
- Pick a default option from the list of choices
|
||||
|
||||

|
||||
|
||||
On the signing page, the "Dropdown/Select" field appears as shown below:
|
||||
|
||||

|
||||
|
||||
Here's how the "Dropdown/Select" field works:
|
||||
|
||||
- If no default value is set, the field will not auto-sign. The signer must click on the field and select an option from the dropdown list to sign it.
|
||||
- After signing, the field displays the selected value, similar to a signed text field.
|
||||
- If the field is marked as required, signers must select a value before completing the signing process.
|
||||
- If the field is marked as read-only, signers can view the selected value but cannot modify it.
|
||||
|
||||
### Checkbox Field
|
||||
|
||||
The last field introduced is the "Checkbox" field, which allows signers to select multiple options from a pre-defined list. This field is helpful for scenarios where signers need to choose multiple items or agree to several terms and conditions, for example.
|
||||
|
||||
Before sending the document for signing, you must add at least one checkbox option. This option can contain a string or an empty value and can be checked or unchecked. Unlike the "Radio" field, the "Checkbox" field can have multiple checked options.
|
||||
|
||||
Like other fields, you can mark the "Checkbox" as _required_ or _read-only_. In addition to that, it also has a validation field, and you can specify how many checkboxes the signer should sign:
|
||||
|
||||
- Select at least X _(a number from 1 to 10)_
|
||||
- Select at most X _(a number from 1 to 10)_
|
||||
- Select exactly X _(a number from 1 to 10)_
|
||||
|
||||

|
||||
|
||||
When a signer receives the document, they will see the "Checkbox" field as shown below:
|
||||
|
||||

|
||||
|
||||
The image illustrates both field states - signed and un-signed. In this example, the 'Checkbox' field has two options checked by default, so it auto-signs.
|
||||
|
||||
The field marked '1' appears when the signer visits the page for the first time or when the user refreshes the page and no option is selected. The field marked '2' displays the cleared state, where all choices have been deselected. This shows how the field looks when a user clears all selections.
|
||||
|
||||
In this example, no validation rule has been set, allowing the signer to select any options. However, when a validation rule is applied, signers must meet the specified criteria to complete the signing process.
|
||||
|
||||
## Development Challenges
|
||||
|
||||
The introduction of these new fields wasn't without its challenges. The main challenges were:
|
||||
|
||||
- Deciding how to store the new information for the fields in the database
|
||||
- Differentiation of recipients using colours
|
||||
- Storing the advanced settings for the local fields on the frontend
|
||||
- Implementing the Checkbox and Radio fields
|
||||
|
||||
### 1st Challenge: Store New Field Information
|
||||
|
||||
The first challenge was deciding how to store the extra information for each new field in the database. Each field has unique properties, with only `required` and `read-only` shared by all the advanced fields.
|
||||
|
||||
The existing `Field` model in the database looks like this:
|
||||
|
||||
```js
|
||||
model Field {
|
||||
id Int @id @default(autoincrement())
|
||||
secondaryId String @unique @default(cuid())
|
||||
documentId Int?
|
||||
templateId Int?
|
||||
recipientId Int
|
||||
type FieldType
|
||||
page Int
|
||||
positionX Decimal @default(0)
|
||||
positionY Decimal @default(0)
|
||||
width Decimal @default(-1)
|
||||
height Decimal @default(-1)
|
||||
customText String
|
||||
inserted Boolean
|
||||
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
Signature Signature?
|
||||
|
||||
@@index([documentId])
|
||||
@@index([templateId])
|
||||
@@index([recipientId])
|
||||
}
|
||||
```
|
||||
|
||||
Initially, we considered creating a new `FieldMeta` table with columns for each field property. However, this approach has 2 issues.
|
||||
|
||||
First, the advanced fields only share two common properties: `required` and `read-only`. Since all the other properties are unique to each field type, this would result in many nullable columns in the `FieldMeta` model.
|
||||
|
||||
Secondly, creating a new database table with columns for each field property and the associated relationships would increase the database complexity.
|
||||
|
||||
As a result, we decided to look for another solution that would better work with our use case.
|
||||
|
||||
### Solution: JSONB Field
|
||||
|
||||
Since the advanced settings data is unique to each field, we decided to store it as JSON using PostgreSQL's `JSONB` data type. We added a new optional `fieldMeta` property of type `JSONB` to the Field model:
|
||||
|
||||
```js
|
||||
model Field {
|
||||
id Int @id @default(autoincrement())
|
||||
secondaryId String @unique @default(cuid())
|
||||
documentId Int?
|
||||
templateId Int?
|
||||
recipientId Int
|
||||
type FieldType
|
||||
page Int
|
||||
positionX Decimal @default(0)
|
||||
positionY Decimal @default(0)
|
||||
width Decimal @default(-1)
|
||||
height Decimal @default(-1)
|
||||
customText String
|
||||
inserted Boolean
|
||||
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
Signature Signature?
|
||||
fieldMeta Json? <<<<<----- added this
|
||||
|
||||
@@index([documentId])
|
||||
@@index([templateId])
|
||||
@@index([recipientId])
|
||||
}
|
||||
```
|
||||
|
||||
This approach allows us to store each field's settings as a JSON object. We use Zod schemas to parse and validate the field metadata when reading from or writing to the database to ensure data integrity.
|
||||
|
||||
This approach has several benefits:
|
||||
|
||||
- **Consistency**: The application uses the same Zod schema to retrieve and insert data into the database. That means the data is consistent throughout the app.
|
||||
- **Type safety**: By parsing the data with Zod, we can guarantee that the data matches the expected types and structure. We can also use Zod's `infer` utility to enable strong typing and autocompletion.
|
||||
- **Better error handling**: Zod provides thorough error messages indicating which part of the data is invalid. That makes it easier & faster to debug and fix issues.
|
||||
- **Maintainability**: Reusing the same Zod schema for retrieving and inserting data into the database makes the data structure easier to maintain.
|
||||
|
||||
However, using `JSONB` also has drawbacks like data querying. Since the data is stored as JSON (more specifically, in binary format), complex queries can be less efficient compared to querying normalized relational data. On top of that, querying data requires specific operators and functions, such as `->`, `->>`, `@>`, and `?`. This makes the querying more verbose and less intuitive, and hence, it requires more finesse.
|
||||
|
||||
Another drawback is the storage overhead. `JSONB` data is stored in a binary format, which can result in some storage overhead compared to normalized relational data. In cases where the JSON data is large or contains a lot of redundant information, the storage overhead can be significant.
|
||||
|
||||
Despite these drawbacks, the `JSONB` type suits our use case, as the field meta information is relatively small and doesn't require complex querying. The flexibility of `JSONB` matches the dynamic nature of the fieldMeta field.
|
||||
|
||||
> Postgres provides 2 fields for storing JSON data — `json` and `jsonb`. For more information, you can [check out the documentation](https://www.postgresql.org/docs/current/datatype-json.html).
|
||||
|
||||
### 2nd Challenge: Storing Fields' Advanced Settings on Frontend
|
||||
|
||||
The next challenge was finding the best way to store the advanced field settings entered by users.
|
||||
|
||||
Currently, the app only saves the fields and associated settings to the database when the user moves to the next step.
|
||||
|
||||

|
||||
|
||||
The fields are stored locally until the user proceeds to the next step. This means all fields and their settings are lost when the user:
|
||||
|
||||
- Closes the advanced settings tab
|
||||
- Refreshes the page
|
||||
- Closes the tab
|
||||
- Navigates to the previous step
|
||||
|
||||
In the future, we plan to improve this flow and save the fields on blur, preserving user data even if they navigate away. However, until then, we needed a solution to save the advanced settings when the user closes the settings tab.
|
||||
|
||||
### Solution: Local Storage
|
||||
|
||||
Our temporary solution is to store the advanced settings in local storage, as the fields are only available locally. If the fields were saved in the database, we could store the advanced settings alongside them.
|
||||
|
||||

|
||||
|
||||
Since the fields are not saved in the database, we must persist the data until the user moves to the next step, at which point the data is saved to the database. Storing the data in local storage allows users to open, close, and configure various fields in the advanced settings tab without losing information.
|
||||
|
||||
When the user proceeds to the next step, the fields and their advanced settings are saved into the database, and the local storage is cleared.
|
||||
|
||||
We also recognized the dangers of saving data to local storage, as users could modify it and break the application. As a result, we have implemented extensive checks on both the backend and frontend, in addition to parsing and validating data with Zod.
|
||||
|
||||
However, this solution has limitations. The data is still lost when the user:
|
||||
|
||||
- Refreshes the page
|
||||
- Navigates to the previous step
|
||||
- Closes the browser
|
||||
|
||||
In these cases, the fields are wiped from the document. A future improvement to save fields to the database on blur will solve this issue.
|
||||
|
||||
### 3rd Challenge: Radio and Checkbox Fields
|
||||
|
||||
Implementing the Radio and Checkbox fields was challenging from both logical and design perspectives. Both fields can contain empty and non-empty values, and the Checkbox field allows users to select multiple empty/non-empty values.
|
||||
|
||||

|
||||
|
||||
The image above shows the Radio and Checkbox fields in the document editor. The Radio field on the left-hand side has 4 options, 1 of which is checked. The Checkbox field on the right-hand side has 4 options, 2 of which are checked.
|
||||
|
||||
The Radio field was easier to implement because users can only select one option, resulting in simpler logic. The signer clicks on an option to choose it, and the field auto-signs with that value. To change the selection, the user clicks another option, un-signing the field and re-signing it with the new value.
|
||||
|
||||
The Checkbox field was more challenging because:
|
||||
|
||||
- Signers can select multiple options simultaneously, resulting in the field containing multiple values.
|
||||
- It can have validation rules (e.g., selecting at least, at most, or exactly X options).
|
||||
- Users can check/uncheck options by clicking them or clear the field with a button.
|
||||
|
||||
These factors make the Checkbox field more complex and challenging to implement correctly.
|
||||
|
||||
### Solution
|
||||
|
||||
Instead of focusing on a specific solution, we'll discuss the general implementation and its most challenging aspects. I'll include a link to the complete implementation for each field so you can check it out.
|
||||
|
||||
**Radio Field**
|
||||
|
||||
The way signing works for the Radio field is to pull the data from the database and display the available options. If the field has a default value set by the document sender, it auto-signs with that value.
|
||||
|
||||
```ts
|
||||
...
|
||||
const values = parsedFieldMeta.values?.map((item) => ({
|
||||
...item,
|
||||
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||
}));
|
||||
...
|
||||
const shouldAutoSignField =
|
||||
(!field.inserted && selectedOption) ||
|
||||
(!field.inserted && defaultValue) ||
|
||||
(!field.inserted && parsedFieldMeta.readOnly && defaultValue);
|
||||
...
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoSignField) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
}
|
||||
}, [selectedOption, field]);
|
||||
```
|
||||
|
||||
> You can see the complete implementation of the radio field in the [radio-field.tsx](<https://github.com/documenso/documenso/blob/main/apps/web/src/app/(signing)/sign/%5Btoken%5D/radio-field.tsx>) file.
|
||||
|
||||
If the field is not read-only and the user clicks on another option, the field un-signs and re-signs with the new value. Read-only fields cannot be modified.
|
||||
|
||||
The value is saved in the database whenever the field is signed, whether by auto-signing or user. Similarly, the value is removed from the database when the field is unsigned.
|
||||
|
||||
Since the Radio field can contain empty values, we map over the values and replace the empty ones with a unique string `empty-value-${item.id}`. This is because the empty string is not a valid value for the field, and we need to differentiate between empty and non-empty values.
|
||||
|
||||
**Checkbox Field**
|
||||
|
||||
The Checkbox field implementation is similar to the Radio field, with the main differences being:
|
||||
|
||||
- Checkbox fields can contain multiple values.
|
||||
- Checkbox fields have validation rules that need to be enforced.
|
||||
|
||||
```ts
|
||||
...
|
||||
const values = parsedFieldMeta.values?.map((item) => ({
|
||||
...item,
|
||||
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||
}));
|
||||
|
||||
const [checkedValues, setCheckedValues] = useState(
|
||||
values
|
||||
?.map((item) =>
|
||||
item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '',
|
||||
)
|
||||
.filter(Boolean) || [],
|
||||
);
|
||||
...
|
||||
```
|
||||
|
||||
As with the Radio field, we map over the values and replace empty ones with a unique string. We also keep track of the checked values to display the field correctly and validate them against the validation rules.
|
||||
|
||||
```ts
|
||||
...
|
||||
const values = parsedFieldMeta.values?.map((item) => ({
|
||||
...item,
|
||||
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||
}));
|
||||
|
||||
const [checkedValues, setCheckedValues] = useState(
|
||||
values
|
||||
?.map((item) =>
|
||||
item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '',
|
||||
)
|
||||
.filter(Boolean) || [],
|
||||
);
|
||||
|
||||
const checkboxValidationRule = parsedFieldMeta.validationRule;
|
||||
const checkboxValidationLength = parsedFieldMeta.validationLength;
|
||||
const validationSign = checkboxValidationSigns.find(
|
||||
(sign) => sign.label === checkboxValidationRule,
|
||||
);
|
||||
...
|
||||
```
|
||||
|
||||
Then, we retrieve the validation rule and length from the database and find the corresponding validation sign (e.g., ">=", "=", "\<=") based on the rule label. The `checkboxValidationSigns` array maps rule labels to their corresponding signs.
|
||||
|
||||
```ts
|
||||
export const checkboxValidationSigns = [
|
||||
{
|
||||
label: 'Select at least',
|
||||
value: '>=',
|
||||
},
|
||||
{
|
||||
label: 'Select exactly',
|
||||
value: '=',
|
||||
},
|
||||
{
|
||||
label: 'Select at most',
|
||||
value: '<=',
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
We then check if the length condition is met based on the validation rule, sign, and length. If met, the user can proceed with signing the field. Otherwise, they need to select the correct number of options.
|
||||
|
||||
```ts
|
||||
...
|
||||
const values = parsedFieldMeta.values?.map((item) => ({
|
||||
...item,
|
||||
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||
}));
|
||||
|
||||
const [checkedValues, setCheckedValues] = useState(
|
||||
values
|
||||
?.map((item) =>
|
||||
item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '',
|
||||
)
|
||||
.filter(Boolean) || [],
|
||||
);
|
||||
|
||||
const checkboxValidationRule = parsedFieldMeta.validationRule;
|
||||
const checkboxValidationLength = parsedFieldMeta.validationLength;
|
||||
const validationSign = checkboxValidationSigns.find(
|
||||
(sign) => sign.label === checkboxValidationRule,
|
||||
);
|
||||
|
||||
const isLengthConditionMet = useMemo(() => {
|
||||
if (!validationSign) return true;
|
||||
return (
|
||||
(validationSign.value === '>=' && checkedValues.length >= (checkboxValidationLength || 0)) ||
|
||||
(validationSign.value === '=' && checkedValues.length === (checkboxValidationLength || 0)) ||
|
||||
(validationSign.value === '<=' && checkedValues.length <= (checkboxValidationLength || 0))
|
||||
);
|
||||
}, [checkedValues, validationSign, checkboxValidationLength]);
|
||||
...
|
||||
```
|
||||
|
||||
In summary, the Checkbox field allows signers to select multiple options, with the field automatically signing based on these selections. Signers can un-sign the field by deselecting options or clearing all selections. The system enforces validation rules throughout this process, ensuring signers select the required number of options to sign the field successfully.
|
||||
|
||||
> You can see the complete implementation of the checkbox field in the [checkbox-field.tsx](<https://github.com/documenso/documenso/blob/main/apps/web/src/app/(signing)/sign/%5Btoken%5D/checkbox-field.tsx>) file.
|
||||
|
||||
### 4th Challenge: Recipients' Colors
|
||||
|
||||
Another challenge we faced was using colours to differentiate recipients. We needed to dynamically generate and reuse the same Tailwind classes across several components. However, TailwindCSS only includes the CSS classes used in the project, discarding unused ones from the final build. This resulted in colours not being applied to the components, as the classes were not used in the code.
|
||||
|
||||
The images below illustrate the recipients' colours in 2 different states.
|
||||
|
||||
In the first image, the "Signature" field on the right is in the active state (blue), triggered when the user clicks the field to drag it onto the document. The signature field on the left, placed on the document, is in the normal state.
|
||||
|
||||
The first image illustrates the "Signature" field in the active state, triggered when the user clicks on it.
|
||||
|
||||

|
||||
|
||||
The second image shows the "Signature" field in the normal state.
|
||||
|
||||

|
||||
|
||||
The document editor consists of various components (fields, recipients, etc.), meaning the same colours and code are reused across multiple components.
|
||||
|
||||
```ts
|
||||
export const combinedStyles = {
|
||||
'orange-500': {
|
||||
ringColor: 'ring-orange-500/30 ring-offset-orange-500',
|
||||
borderWithHover: 'border-orange-500 hover:border-orange-500',
|
||||
...,
|
||||
},
|
||||
'green-500': {
|
||||
ringColor: 'ring-green-500/30 ring-offset-green-500',
|
||||
borderWithHover: 'border-green-500 hover:border-green-500',
|
||||
...,
|
||||
},
|
||||
'blue-500': {
|
||||
ringColor: 'ring-blue-500/30 ring-offset-blue-500',
|
||||
borderWithHover: 'border-blue-500 hover:border-blue-500',
|
||||
...,
|
||||
'gray-500': {
|
||||
ringColor: 'ring-gray-500/30 ring-offset-gray-500',
|
||||
borderWithHover: 'border-gray-500 hover:border-gray-500',
|
||||
...,
|
||||
},
|
||||
...,
|
||||
};
|
||||
|
||||
export const MyComponent = () => {
|
||||
const selectedSignerStyles = useSelectedSignerStyles(selectedSigner, combinedStyles);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
selectedSigner ? selectedSignerStyles.ringClass : selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
<h1>Hello</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
The code above shows a naive solution using a `combinedStyles` object containing TailwindCSS classes for various component styles (ring, border, hover, etc.).
|
||||
|
||||
Components would use custom hooks to apply appropriate styles based on the selected recipient. For example, recipient 1 would use `green-500` styles, turning all related elements green.
|
||||
|
||||

|
||||
|
||||
The problem with this approach is that we can't import the `combinedStyles` object into other components because TailwindCSS will remove the unused classes. That means we had to copy and paste the same object into multiple files. As a result, it pollutes the codebase with duplicated code, which makes it harder to maintain and scale the code. As the application grows, the `combinedStyles` object will become larger and more complex. Moreover, it's not very flexible, as it doesn't allow for easy customization of the colours.
|
||||
|
||||
While this approach works, there is a more efficient and scalable solution.
|
||||
|
||||
### Solution: Modularise the Logic and Use CSS Variables
|
||||
|
||||
To address the challenge of reusing colours across components, we moved the colours and associated hooks to a separate file, defining styles only in this file and accessing them from components through custom hooks.
|
||||
|
||||
```ts
|
||||
export const SIGNER_COLOR_STYLES = {
|
||||
green: {
|
||||
default: {
|
||||
background: 'bg-[hsl(var(--signer-green))]',
|
||||
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-green)/10%),0_0_0_2px_hsl(var(--signer-green)/60%),0_0_0_0.5px_hsl(var(--signer-green))]',
|
||||
fieldItem:
|
||||
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-green))]/10 hover:to-[hsl(var(--signer-green))]/10',
|
||||
fieldItemInitials:
|
||||
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-green))]',
|
||||
comboxBoxItem: 'hover:bg-[hsl(var(--signer-green)/15%)] active:bg-[hsl(var(--signer-green)/15%)]',
|
||||
},
|
||||
},
|
||||
|
||||
...
|
||||
};
|
||||
|
||||
export type CombinedStylesKey = keyof typeof SIGNER_COLOR_STYLES;
|
||||
|
||||
export const AVAILABLE_SIGNER_COLORS = [
|
||||
'green',
|
||||
'blue',
|
||||
'purple',
|
||||
'orange',
|
||||
'yellow',
|
||||
'pink',
|
||||
] as const satisfies CombinedStylesKey[];
|
||||
|
||||
export const useSignerColors = (index: number) => {
|
||||
const key = AVAILABLE_SIGNER_COLORS[index % AVAILABLE_SIGNER_COLORS.length];
|
||||
|
||||
return SIGNER_COLOR_STYLES[key];
|
||||
};
|
||||
|
||||
export const getSignerColorStyles = (index: number) => {
|
||||
return useSignerColors(index);
|
||||
};
|
||||
```
|
||||
|
||||
> The file was truncated for readability. You can see the complete code in the [signer-colors.ts](https://github.com/documenso/documenso/blob/main/packages/ui/lib/signer-colors.ts) file from the Documenso repository.
|
||||
|
||||
The `SIGNER_COLOR_STYLES` object contains the styles for each colour, such as the background, border, and hover colours. Based on the signer's index, the `useSignerColors` hook gets the styles for a specific colour. The `getSignerColorStyles` function is a helper function that returns the styles for a particular signer.
|
||||
|
||||
Now, the components can access the colours and styles using custom hooks. For example, to get the styles for a specific signer, the component can call the `useSignerColors` hook with the signer's index.
|
||||
|
||||
```ts
|
||||
const signerStyles = useSignerColors(recipientIndex);
|
||||
```
|
||||
|
||||
The hook will return the styles for that signer, which can then be applied to the component. For example, you can access the signer's background colour using `signerStyles.default.background`.
|
||||
|
||||
This approach makes managing the colours and styles easier, as they are defined in a single file. Changing or adding colours can be done in one place, making the code more modular and reusable.
|
||||
|
||||
We also opted for CSS variables to define colours, allowing more flexibility and consistency in styling. A single CSS variable for each colour can cover a wide range of states without relying on multiple TailwindCSS classes. For example, you can easily set the opacity and lightness of colour without using multiple classes. CSS variables help align colours with our brand guidelines while simplifying the overall styling process.
|
||||
|
||||
## The End
|
||||
|
||||
We're happy to see the new advanced fields released because they offer our users more flexibility, variety, and customization options. Implementing the new fields came with its challenges, but we overcame them and learned from them. We're excited to continue enhancing Documenso and providing our users with the best document signing experience.
|
||||
@ -8,7 +8,107 @@ Check out what's new in the latest version and read our thoughts on it. For more
|
||||
|
||||
---
|
||||
|
||||
## v1.5.6 (latest)
|
||||
# Documenso v1.6.1: Internationalization, Enhanced OIDC, and More
|
||||
|
||||
We're excited to announce the release of Documenso v1.6.1, which brings several improvements to enhance your document signing experience. Here are the key updates:
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
### New Initials Field Type
|
||||
|
||||
We've added a new field type for initials, giving you more options for document customization. This feature allows signers to quickly initial documents, adding an extra layer of verification to your signing process.
|
||||
|
||||
### Internationalization Support
|
||||
|
||||
We've taken a big step towards making Documenso accessible to a global audience by adding i18n (internationalization) support for our marketing pages and adding translations to support multiple languages.
|
||||
|
||||
While this is just a small step towards a fully multilingual Documenso, it's a significant step towards making our platform more accessible to a global audience.
|
||||
|
||||
Using our new knowledge and findings from the marketing implementation, we aim to tackle our web application in the near future for a fully global Documenso.
|
||||
|
||||
### Enhanced OpenID Connect (OIDC) Integration
|
||||
|
||||
For our self-hosted users leveraging OIDC for authentication:
|
||||
|
||||
- Now supports OIDC-only signup
|
||||
- Added trust for email addresses from OIDC providers
|
||||
- The OIDC sign-in button text is now configurable
|
||||
|
||||
## 🔧 Other Improvements
|
||||
|
||||
- **UI Enhancements**:
|
||||
|
||||
- Fixed display issues with field names/labels in dark mode
|
||||
- Improved truncation of titles to prevent UI breaks
|
||||
|
||||
- **User Experience**:
|
||||
|
||||
- The signup option is now shown only to users without existing accounts
|
||||
- Fixed issues with radio and checkbox fields having empty values
|
||||
|
||||
- **API and Security**:
|
||||
|
||||
- Fixed a bug in the date format API
|
||||
- Improved URL parsing for enhanced security
|
||||
- Added support for dynamic external IDs for direct templates
|
||||
|
||||
- **Document Management**:
|
||||
- Resolved an issue with downloading audit log certificates
|
||||
|
||||
We've also made various other minor fixes and improvements to ensure a smoother Documenso experience.
|
||||
|
||||
## 👏 Community Contributions
|
||||
|
||||
A big thank you to our growing community! This release includes contributions from several new contributors, showcasing the power of open-source collaboration.
|
||||
|
||||
We appreciate your continued support and feedback as we work to make Documenso the best document signing solution available. Enjoy the new features and improvements in v1.6.1!
|
||||
|
||||
---
|
||||
|
||||
## v1.6.0: Enhancing Team Collaboration and User Experience
|
||||
|
||||
### <small>Released 23th July 2024</small>
|
||||
|
||||
> This release contains [8 fixes](https://github.com/documenso/documenso/releases/tag/v1.6.0)
|
||||
|
||||
We're excited to announce the release of Documenso v1.6.0! The new release is packed with new features and improvements to streamline the signing process. Here are the highlights:
|
||||
|
||||
### 📚 New Documentation Site
|
||||
|
||||
We’ve launched a comprehensive documentation site to help you make the most of Documenso. Check it out to explore all our features and best practices! Based on your feedback, we’re constantly working to improve Documenso. Feel free to ping us to update the docs or even raise PRs.
|
||||
|
||||
> Check out the docs at [https://docs.documenso.com](https://docs.documenso.com)
|
||||
|
||||
### ✅ Enhanced Field Types
|
||||
|
||||
We've significantly expanded our field options to give you more document and template creation flexibility. New field types include:
|
||||
|
||||
- Dropdown Menus
|
||||
- Checkboxes
|
||||
- Radio Buttons
|
||||
- Number Fields
|
||||
|
||||
We've added more customization options for each field type, allowing you to create more dynamic and interactive documents. All non-auto field types allow setting pre-fill values, placeholders, and labels. For more details, see the new [documentation](https://docs.documenso.com/users/signing-documents/fields).
|
||||
|
||||
### 🪪 Public Profiles
|
||||
|
||||
We've introduced [Public Profiles](http://localhost:3001/blog/announcing-profiles), allowing you to showcase your professional identity within Documenso and showcase your publicly available templates. This feature enhances transparency and trust in your signing processes. This feature enhances transparency and trust in your signing processes.
|
||||
|
||||
### ⬅️ Move Documents and Templates to Teams
|
||||
|
||||
Did you accidentally create a document under your personal account? No problem! You can move documents and templates between your personal account and team workspaces, facilitating better organization and teamwork.
|
||||
|
||||
### 🔧 Other Improvements
|
||||
|
||||
- Background Tasks: We've implemented a system for handling background tasks, improving overall performance and responsiveness.
|
||||
- Force Signature Fields: Document creators can now ensure that signers complete all required signature fields.
|
||||
- Custom Emails for Direct Template Documents: Personalize your communication by sending custom emails to signers of direct template documents.
|
||||
- API Enhancements: We've added more template API endpoints and the ability to resend documents via API, giving developers more flexibility.
|
||||
- Anonymous SMTP Authentication: We now support anonymous SMTP authentication for those who need it.
|
||||
|
||||
---
|
||||
|
||||
## v1.5.6
|
||||
|
||||
### <small>Released 28th June 2024</small>
|
||||
|
||||
|
||||
22
apps/marketing/lingui.config.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { LinguiConfig } from '@lingui/conf';
|
||||
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
|
||||
// Extends root lingui.config.cjs.
|
||||
const config: LinguiConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
locales: APP_I18N_OPTIONS.supportedLangs as unknown as string[],
|
||||
catalogs: [
|
||||
{
|
||||
path: '<rootDir>/../../packages/lib/translations/{locale}/marketing',
|
||||
include: ['<rootDir>/apps/marketing/src'],
|
||||
},
|
||||
{
|
||||
path: '<rootDir>/../../packages/lib/translations/{locale}/common',
|
||||
include: ['<rootDir>/packages/ui', '<rootDir>/packages/lib'],
|
||||
},
|
||||
],
|
||||
catalogsMergePath: '<rootDir>/../../packages/lib/translations/{locale}/marketing',
|
||||
};
|
||||
|
||||
export default config;
|
||||
@ -30,6 +30,7 @@ const config = {
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
swcPlugins: [['@lingui/swc-plugin', {}]],
|
||||
},
|
||||
reactStrictMode: true,
|
||||
transpilePackages: [
|
||||
@ -55,6 +56,13 @@ const config = {
|
||||
config.resolve.alias.canvas = false;
|
||||
}
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.po$/,
|
||||
use: {
|
||||
loader: '@lingui/loader',
|
||||
},
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
async headers() {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/marketing",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0-rc.2",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -10,7 +10,8 @@
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"clean": "rimraf .next && rimraf node_modules",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs",
|
||||
"translate:compile": "lingui compile --typescript"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/assets": "*",
|
||||
@ -19,7 +20,9 @@
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@lingui/react": "^4.11.1",
|
||||
"@openstatus/react": "^0.0.3",
|
||||
"cmdk": "^0.2.1",
|
||||
"contentlayer": "^0.3.4",
|
||||
"embla-carousel": "^8.1.3",
|
||||
"embla-carousel-autoplay": "^8.1.3",
|
||||
@ -46,6 +49,8 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lingui/loader": "^4.11.1",
|
||||
"@lingui/swc-plugin": "4.0.6",
|
||||
"@types/node": "20.1.0",
|
||||
"@types/react": "18.2.18",
|
||||
"@types/react-dom": "18.2.7"
|
||||
|
||||
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 65 KiB |
BIN
apps/marketing/public/blog/advanced-fields/old-text-field.jpeg
Normal file
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/marketing/public/blog/roles.webp
Normal file
|
After Width: | Height: | Size: 76 KiB |
@ -1,12 +1,17 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { allBlogPosts } from 'contentlayer/generated';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog',
|
||||
};
|
||||
|
||||
export default function BlogPage() {
|
||||
const { i18n } = setupI18nSSR();
|
||||
|
||||
const blogPosts = allBlogPosts.sort((a, b) => {
|
||||
const dateA = new Date(a.date);
|
||||
const dateB = new Date(b.date);
|
||||
@ -17,11 +22,15 @@ export default function BlogPage() {
|
||||
return (
|
||||
<div className="mt-6 sm:mt-12">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold lg:text-5xl">From the blog</h1>
|
||||
<h1 className="text-3xl font-bold lg:text-5xl">
|
||||
<Trans>From the blog</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mx-auto mt-4 max-w-xl text-center text-lg leading-normal">
|
||||
Get the latest news from Documenso, including product updates, team announcements and
|
||||
more!
|
||||
<Trans>
|
||||
Get the latest news from Documenso, including product updates, team announcements and
|
||||
more!
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -33,7 +42,7 @@ export default function BlogPage() {
|
||||
>
|
||||
<div className="flex items-center gap-x-4 text-xs">
|
||||
<time dateTime={post.date} className="text-muted-foreground">
|
||||
{new Date(post.date).toLocaleDateString()}
|
||||
<Trans>{i18n.date(new Date(post.date), { dateStyle: 'short' })}</Trans>
|
||||
</time>
|
||||
|
||||
{post.tags.length > 0 && (
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
|
||||
export const TEAM_MEMBERS = [
|
||||
{
|
||||
name: 'Timur Ercan',
|
||||
role: 'Co-Founder, CEO',
|
||||
salary: 95_000,
|
||||
location: 'Germany',
|
||||
engagement: 'Full-Time',
|
||||
engagement: msg`Full-Time`,
|
||||
joinDate: 'November 14th, 2022',
|
||||
},
|
||||
{
|
||||
@ -12,7 +14,7 @@ export const TEAM_MEMBERS = [
|
||||
role: 'Co-Founder, CTO',
|
||||
salary: 95_000,
|
||||
location: 'Australia',
|
||||
engagement: 'Full-Time',
|
||||
engagement: msg`Full-Time`,
|
||||
joinDate: 'April 19th, 2023',
|
||||
},
|
||||
{
|
||||
@ -20,7 +22,7 @@ export const TEAM_MEMBERS = [
|
||||
role: 'Software Engineer - Intern',
|
||||
salary: 15_000,
|
||||
location: 'Ghana',
|
||||
engagement: 'Part-Time',
|
||||
engagement: msg`Part-Time`,
|
||||
joinDate: 'June 6th, 2023',
|
||||
},
|
||||
{
|
||||
@ -28,7 +30,7 @@ export const TEAM_MEMBERS = [
|
||||
role: 'Software Engineer - III',
|
||||
salary: 100_000,
|
||||
location: 'Australia',
|
||||
engagement: 'Full-Time',
|
||||
engagement: msg`Full-Time`,
|
||||
joinDate: 'July 26th, 2023',
|
||||
},
|
||||
{
|
||||
@ -36,7 +38,7 @@ export const TEAM_MEMBERS = [
|
||||
role: 'Software Engineer - II',
|
||||
salary: 80_000,
|
||||
location: 'Romania',
|
||||
engagement: 'Full-Time',
|
||||
engagement: msg`Full-Time`,
|
||||
joinDate: 'September 4th, 2023',
|
||||
},
|
||||
{
|
||||
@ -44,7 +46,7 @@ export const TEAM_MEMBERS = [
|
||||
role: 'Designer - III',
|
||||
salary: 100_000,
|
||||
location: 'India',
|
||||
engagement: 'Full-Time',
|
||||
engagement: msg`Full-Time`,
|
||||
joinDate: 'October 9th, 2023',
|
||||
},
|
||||
];
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { formatMonth } from '@documenso/lib/client-only/format-month';
|
||||
@ -11,6 +13,8 @@ export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
|
||||
};
|
||||
|
||||
export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const formattedData = data.map((item) => ({
|
||||
amount: Number(item.amount),
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
@ -21,7 +25,9 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps)
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>Total Funding Raised</Trans>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
@ -49,14 +55,14 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps)
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}),
|
||||
'Amount Raised',
|
||||
_(msg`Amount Raised`),
|
||||
]}
|
||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="amount"
|
||||
fill="hsl(var(--primary))"
|
||||
label="Amount Raised"
|
||||
label={_(msg`Amount Raised`)}
|
||||
maxBarSize={60}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
@ -14,6 +16,8 @@ export const MonthlyCompletedDocumentsChart = ({
|
||||
className,
|
||||
data,
|
||||
}: MonthlyCompletedDocumentsChartProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const formattedData = [...data].reverse().map(({ month, count }) => {
|
||||
return {
|
||||
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
|
||||
@ -25,7 +29,9 @@ export const MonthlyCompletedDocumentsChart = ({
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>Completed Documents per Month</Trans>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
@ -46,7 +52,7 @@ export const MonthlyCompletedDocumentsChart = ({
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
label="Completed Documents"
|
||||
label={_(msg`Completed Documents`)}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
@ -11,6 +13,8 @@ export type MonthlyNewUsersChartProps = {
|
||||
};
|
||||
|
||||
export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const formattedData = [...data].reverse().map(({ month, count }) => {
|
||||
return {
|
||||
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
|
||||
@ -22,7 +26,9 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>New Users</Trans>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
@ -34,7 +40,7 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
|
||||
labelStyle={{
|
||||
color: 'hsl(var(--primary-foreground))',
|
||||
}}
|
||||
formatter={(value) => [Number(value).toLocaleString('en-US'), 'New Users']}
|
||||
formatter={(value) => [Number(value).toLocaleString('en-US'), _(msg`New Users`)]}
|
||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||
/>
|
||||
|
||||
@ -43,7 +49,7 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
label="New Users"
|
||||
label={_(msg`New Users`)}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
@ -11,6 +13,8 @@ export type MonthlyTotalUsersChartProps = {
|
||||
};
|
||||
|
||||
export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersChartProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const formattedData = [...data].reverse().map(({ month, cume_count: count }) => {
|
||||
return {
|
||||
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
|
||||
@ -22,7 +26,9 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>Total Users</Trans>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
@ -34,7 +40,7 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
|
||||
labelStyle={{
|
||||
color: 'hsl(var(--primary-foreground))',
|
||||
}}
|
||||
formatter={(value) => [Number(value).toLocaleString('en-US'), 'Total Users']}
|
||||
formatter={(value) => [Number(value).toLocaleString('en-US'), _(msg`Total Users`)]}
|
||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||
/>
|
||||
|
||||
@ -43,7 +49,7 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
label="Total Users"
|
||||
label={_(msg`Total Users`)}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getCompletedDocumentsMonthly } from '@documenso/lib/server-only/user/get-monthly-completed-document';
|
||||
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||
|
||||
@ -128,6 +131,10 @@ const fetchEarlyAdopters = async () => {
|
||||
};
|
||||
|
||||
export default async function OpenPage() {
|
||||
setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [
|
||||
{ forks_count: forksCount, stargazers_count: stargazersCount },
|
||||
{ total_count: openIssues },
|
||||
@ -150,19 +157,23 @@ export default async function OpenPage() {
|
||||
<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>
|
||||
<h1 className="text-3xl font-bold lg:text-5xl">
|
||||
<Trans>Open Startup</Trans>
|
||||
</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>
|
||||
<Trans>
|
||||
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>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -180,12 +191,12 @@ export default async function OpenPage() {
|
||||
/>
|
||||
<MetricCard
|
||||
className="col-span-2 lg:col-span-1"
|
||||
title="Open Issues"
|
||||
title={_(msg`Open Issues`)}
|
||||
value={openIssues.toLocaleString('en-US')}
|
||||
/>
|
||||
<MetricCard
|
||||
className="col-span-2 lg:col-span-1"
|
||||
title="Merged PR's"
|
||||
title={_(msg`Merged PR's`)}
|
||||
value={mergedPullRequests.toLocaleString('en-US')}
|
||||
/>
|
||||
</div>
|
||||
@ -195,28 +206,32 @@ export default async function OpenPage() {
|
||||
<SalaryBands className="col-span-12" />
|
||||
</div>
|
||||
|
||||
<h2 className="px-4 text-2xl font-semibold">Finances</h2>
|
||||
<h2 className="px-4 text-2xl font-semibold">
|
||||
<Trans>Finances</Trans>
|
||||
</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>
|
||||
<h2 className="px-4 text-2xl font-semibold">
|
||||
<Trans>Community</Trans>
|
||||
</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"
|
||||
title={_(msg`GitHub: Total Stars`)}
|
||||
label={_(msg`Stars`)}
|
||||
className="col-span-12 lg:col-span-6"
|
||||
/>
|
||||
|
||||
<BarMetric<StargazersType>
|
||||
data={STARGAZERS_DATA}
|
||||
metricKey="mergedPRs"
|
||||
title="GitHub: Total Merged PRs"
|
||||
label="Merged PRs"
|
||||
title={_(msg`GitHub: Total Merged PRs`)}
|
||||
label={_(msg`Merged PRs`)}
|
||||
chartHeight={400}
|
||||
className="col-span-12 lg:col-span-6"
|
||||
/>
|
||||
@ -233,8 +248,8 @@ export default async function OpenPage() {
|
||||
<BarMetric<StargazersType>
|
||||
data={STARGAZERS_DATA}
|
||||
metricKey="openIssues"
|
||||
title="GitHub: Total Open Issues"
|
||||
label="Open Issues"
|
||||
title={_(msg`GitHub: Total Open Issues`)}
|
||||
label={_(msg`Open Issues`)}
|
||||
chartHeight={400}
|
||||
className="col-span-12 lg:col-span-6"
|
||||
/>
|
||||
@ -242,13 +257,15 @@ export default async function OpenPage() {
|
||||
<Typefully className="col-span-12 lg:col-span-6" />
|
||||
</div>
|
||||
|
||||
<h2 className="px-4 text-2xl font-semibold">Growth</h2>
|
||||
<h2 className="px-4 text-2xl font-semibold">
|
||||
<Trans>Growth</Trans>
|
||||
</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"
|
||||
title={_(msg`Total Customers`)}
|
||||
label={_(msg`Total Customers`)}
|
||||
className="col-span-12 lg:col-span-6"
|
||||
extraInfo={<OpenPageTooltip />}
|
||||
/>
|
||||
@ -268,11 +285,15 @@ export default async function OpenPage() {
|
||||
</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>
|
||||
<h2 className="text-2xl font-bold">
|
||||
<Trans>Is there more?</Trans>
|
||||
</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.
|
||||
<Trans>
|
||||
This page is evolving as we learn what makes a great signing company. We'll update it
|
||||
when we have more to share.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import {
|
||||
Table,
|
||||
@ -17,15 +19,23 @@ export type SalaryBandsProps = HTMLAttributes<HTMLDivElement>;
|
||||
export const SalaryBands = ({ className, ...props }: SalaryBandsProps) => {
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)} {...props}>
|
||||
<h3 className="px-4 text-lg font-semibold">Global Salary Bands</h3>
|
||||
<h3 className="px-4 text-lg font-semibold">
|
||||
<Trans>Global Salary Bands</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="border-border mt-2.5 flex-1 rounded-2xl border shadow-sm hover:shadow">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">Title</TableHead>
|
||||
<TableHead>Seniority</TableHead>
|
||||
<TableHead className="w-[100px] text-right">Salary</TableHead>
|
||||
<TableHead className="w-[200px]">
|
||||
<Trans>Title</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Seniority</Trans>
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px] text-right">
|
||||
<Trans>Salary</Trans>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import {
|
||||
Table,
|
||||
@ -15,20 +18,36 @@ import { TEAM_MEMBERS } from './data';
|
||||
export type TeamMembersProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TeamMembers = ({ className, ...props }: TeamMembersProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)} {...props}>
|
||||
<h2 className="px-4 text-2xl font-semibold">Team</h2>
|
||||
<h2 className="px-4 text-2xl font-semibold">
|
||||
<Trans>Team</Trans>
|
||||
</h2>
|
||||
|
||||
<div className="border-border mt-2.5 flex-1 rounded-2xl border shadow-sm hover:shadow">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="">Name</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Salary</TableHead>
|
||||
<TableHead>Engagement</TableHead>
|
||||
<TableHead>Location</TableHead>
|
||||
<TableHead className="w-[100px] text-right">Join Date</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Name</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Role</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Salary</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Engagement</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Location</Trans>
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px] text-right">
|
||||
<Trans>Join Date</Trans>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -44,7 +63,7 @@ export const TeamMembers = ({ className, ...props }: TeamMembersProps) => {
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>{member.engagement}</TableCell>
|
||||
<TableCell>{_(member.engagement)}</TableCell>
|
||||
<TableCell>{member.location}</TableCell>
|
||||
<TableCell className="text-right">{member.joinDate}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@ -29,7 +31,9 @@ export function OpenPageTooltip() {
|
||||
</svg>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Customers with an Active Subscriptions.</p>
|
||||
<p>
|
||||
<Trans>Customers with an Active Subscriptions.</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
@ -11,6 +13,8 @@ export type TotalSignedDocumentsChartProps = {
|
||||
};
|
||||
|
||||
export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocumentsChartProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const formattedData = [...data].reverse().map(({ month, cume_count: count }) => {
|
||||
return {
|
||||
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
|
||||
@ -22,7 +26,9 @@ export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocume
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>Total Completed Documents</Trans>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
@ -46,7 +52,7 @@ export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocume
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
label="Total Completed Documents"
|
||||
label={_(msg`Total Completed Documents`)}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@ -4,6 +4,7 @@ import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { FaXTwitter } from 'react-icons/fa6';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -15,22 +16,26 @@ export const Typefully = ({ className, ...props }: TypefullyProps) => {
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>Twitter Stats</Trans>
|
||||
</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>
|
||||
<h1>
|
||||
<Trans>Documenso on X</Trans>
|
||||
</h1>
|
||||
</Link>
|
||||
<Button className="rounded-full" size="sm" asChild>
|
||||
<Link href="https://typefully.com/documenso/stats" target="_blank">
|
||||
View all stats
|
||||
<Trans>View all stats</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button className="rounded-full bg-white" size="sm" asChild>
|
||||
<Link href="https://twitter.com/documenso" target="_blank">
|
||||
Follow us on X
|
||||
<Trans>Follow us on X</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -44,6 +44,10 @@ export default async function OSSFriendsPage() {
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="-mr-[15vw] -mt-[15vh] h-full max-h-[150vh] scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:-mr-[50vw] md:scale-150 lg:scale-[175%]"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Caveat } from 'next/font/google';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { Callout } from '~/components/(marketing)/callout';
|
||||
@ -25,6 +26,8 @@ const fontCaveat = Caveat({
|
||||
});
|
||||
|
||||
export default async function IndexPage() {
|
||||
setupI18nSSR();
|
||||
|
||||
const starCount = await fetch('https://api.github.com/repos/documenso/documenso', {
|
||||
headers: {
|
||||
accept: 'application/vnd.github.v3+json',
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@ -28,15 +31,21 @@ export type PricingPageProps = {
|
||||
};
|
||||
|
||||
export default function PricingPage() {
|
||||
setupI18nSSR();
|
||||
|
||||
return (
|
||||
<div className="mt-6 sm:mt-12">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold lg:text-5xl">Pricing</h1>
|
||||
<h1 className="text-3xl font-bold lg:text-5xl">
|
||||
<Trans>Pricing</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-foreground mt-4 text-lg leading-normal">
|
||||
Designed for every stage of your journey.
|
||||
<Trans>Designed for every stage of your journey.</Trans>
|
||||
</p>
|
||||
<p className="text-foreground text-lg leading-normal">
|
||||
<Trans>Get started today.</Trans>
|
||||
</p>
|
||||
<p className="text-foreground text-lg leading-normal">Get started today.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
@ -49,19 +58,21 @@ export default function PricingPage() {
|
||||
|
||||
<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!
|
||||
<Trans>None of these work for you? Try self-hosting!</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-center leading-relaxed">
|
||||
Our self-hosted option is great for small teams and individuals who need a simple
|
||||
solution. You can use our docker based setup to get started in minutes. Take control with
|
||||
full customizability and data ownership.
|
||||
<Trans>
|
||||
Our self-hosted option is great for small teams and individuals who need a simple
|
||||
solution. You can use our docker based setup to get started in minutes. Take control
|
||||
with full customizability and data ownership.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button variant="outline" size="lg" className="rounded-full hover:cursor-pointer" asChild>
|
||||
<Link href="https://github.com/documenso/documenso" target="_blank" rel="noreferrer">
|
||||
Get Started
|
||||
<Trans>Get Started</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@ -75,120 +86,134 @@ export default function PricingPage() {
|
||||
<Accordion type="multiple" className="mt-8">
|
||||
<AccordionItem value="plan-differences">
|
||||
<AccordionTrigger className="text-left text-lg font-semibold">
|
||||
What is the difference between the plans?
|
||||
<Trans>What is the difference between the plans?</Trans>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||
You can self-host Documenso for free or use our ready-to-use hosted version. The
|
||||
hosted version comes with additional support, painless scalability and more. Early
|
||||
adopters will get access to all features we build this year, for no additional cost!
|
||||
Forever! Yes, that includes multiple users per account later. If you want Documenso
|
||||
for your enterprise, we are happy to talk about your needs.
|
||||
<Trans>
|
||||
You can self-host Documenso for free or use our ready-to-use hosted version. The
|
||||
hosted version comes with additional support, painless scalability and more. Early
|
||||
adopters will get access to all features we build this year, for no additional cost!
|
||||
Forever! Yes, that includes multiple users per account later. If you want Documenso
|
||||
for your enterprise, we are happy to talk about your needs.
|
||||
</Trans>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="data-handling">
|
||||
<AccordionTrigger className="text-left text-lg font-semibold">
|
||||
How do you handle my data?
|
||||
<Trans>How do you handle my data?</Trans>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||
Securely. Our data centers are located in Frankfurt (Germany), giving us the best
|
||||
local privacy laws. We are very aware of the sensitive nature of our data and follow
|
||||
best practices to ensure the security and integrity of the data entrusted to us.
|
||||
<Trans>
|
||||
Securely. Our data centers are located in Frankfurt (Germany), giving us the best
|
||||
local privacy laws. We are very aware of the sensitive nature of our data and follow
|
||||
best practices to ensure the security and integrity of the data entrusted to us.
|
||||
</Trans>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="should-use-cloud">
|
||||
<AccordionTrigger className="text-left text-lg font-semibold">
|
||||
Why should I use your hosting service?
|
||||
<Trans>Why should I use your hosting service?</Trans>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||
Using our hosted version is the easiest way to get started, you can simply subscribe
|
||||
and start signing your documents. We take care of the infrastructure, so you can focus
|
||||
on your business. Additionally, when using our hosted version you benefit from our
|
||||
trusted signing certificates which helps you to build trust with your customers.
|
||||
<Trans>
|
||||
Using our hosted version is the easiest way to get started, you can simply subscribe
|
||||
and start signing your documents. We take care of the infrastructure, so you can
|
||||
focus on your business. Additionally, when using our hosted version you benefit from
|
||||
our trusted signing certificates which helps you to build trust with your customers.
|
||||
</Trans>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="how-to-contribute">
|
||||
<AccordionTrigger className="text-left text-lg font-semibold">
|
||||
How can I contribute?
|
||||
<Trans>How can I contribute?</Trans>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||
That's awesome. You can take a look at the current{' '}
|
||||
<Link
|
||||
className="text-documenso-700 font-bold"
|
||||
href="https://github.com/documenso/documenso/milestones"
|
||||
target="_blank"
|
||||
>
|
||||
Issues
|
||||
</Link>{' '}
|
||||
and join our{' '}
|
||||
<Link
|
||||
className="text-documenso-700 font-bold"
|
||||
href="https://documen.so/discord"
|
||||
target="_blank"
|
||||
>
|
||||
Discord Community
|
||||
</Link>{' '}
|
||||
to keep up to date, on what the current priorities are. In any case, we are an open
|
||||
community and welcome all input, technical and non-technical ❤️
|
||||
<Trans>
|
||||
That's awesome. You can take a look at the current{' '}
|
||||
<Link
|
||||
className="text-documenso-700 font-bold"
|
||||
href="https://github.com/documenso/documenso/milestones"
|
||||
target="_blank"
|
||||
>
|
||||
Issues
|
||||
</Link>{' '}
|
||||
and join our{' '}
|
||||
<Link
|
||||
className="text-documenso-700 font-bold"
|
||||
href="https://documen.so/discord"
|
||||
target="_blank"
|
||||
>
|
||||
Discord Community
|
||||
</Link>{' '}
|
||||
to keep up to date, on what the current priorities are. In any case, we are an open
|
||||
community and welcome all input, technical and non-technical ❤️
|
||||
</Trans>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="can-i-use-documenso-commercially">
|
||||
<AccordionTrigger className="text-left text-lg font-semibold">
|
||||
Can I use Documenso commercially?
|
||||
<Trans>Can I use Documenso commercially?</Trans>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||
Yes! Documenso is offered under the GNU AGPL V3 open source license. This means you
|
||||
can use it for free and even modify it to fit your needs, as long as you publish your
|
||||
changes under the same license.
|
||||
<Trans>
|
||||
Yes! Documenso is offered under the GNU AGPL V3 open source license. This means you
|
||||
can use it for free and even modify it to fit your needs, as long as you publish
|
||||
your changes under the same license.
|
||||
</Trans>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="why-prefer-documenso">
|
||||
<AccordionTrigger className="text-left text-lg font-semibold">
|
||||
Why should I prefer Documenso over DocuSign or some other signing tool?
|
||||
<Trans>Why should I prefer Documenso over DocuSign or some other signing tool?</Trans>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||
Documenso is a community effort to create an open and vibrant ecosystem around a tool,
|
||||
everybody is free to use and adapt. By being truly open we want to create trusted
|
||||
infrastructure for the future of the internet.
|
||||
<Trans>
|
||||
Documenso is a community effort to create an open and vibrant ecosystem around a
|
||||
tool, everybody is free to use and adapt. By being truly open we want to create
|
||||
trusted infrastructure for the future of the internet.
|
||||
</Trans>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="where-can-i-get-support">
|
||||
<AccordionTrigger className="text-left text-lg font-semibold">
|
||||
Where can I get support?
|
||||
<Trans>Where can I get support?</Trans>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||
We are happy to assist you at{' '}
|
||||
<Link
|
||||
className="text-documenso-700 font-bold"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="mailto:support@documenso.com"
|
||||
>
|
||||
support@documenso.com
|
||||
</Link>{' '}
|
||||
or{' '}
|
||||
<a
|
||||
className="text-documenso-700 font-bold"
|
||||
href="https://documen.so/discord"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
in our Discord-Support-Channel
|
||||
</a>{' '}
|
||||
please message either Lucas or Timur to get added to the channel if you are not
|
||||
already a member.
|
||||
<Trans>
|
||||
We are happy to assist you at{' '}
|
||||
<Link
|
||||
className="text-documenso-700 font-bold"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="mailto:support@documenso.com"
|
||||
>
|
||||
support@documenso.com
|
||||
</Link>{' '}
|
||||
or{' '}
|
||||
<a
|
||||
className="text-documenso-700 font-bold"
|
||||
href="https://documen.so/discord"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
in our Discord-Support-Channel
|
||||
</a>{' '}
|
||||
please message either Lucas or Timur to get added to the channel if you are not
|
||||
already a member.
|
||||
</Trans>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
@ -5,6 +5,8 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
|
||||
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';
|
||||
@ -46,8 +48,8 @@ export const SinglePlayerClient = () => {
|
||||
|
||||
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
||||
fields: {
|
||||
title: 'Add document',
|
||||
description: 'Upload a document and add fields.',
|
||||
title: msg`Add document`,
|
||||
description: msg`Upload a document and add fields.`,
|
||||
stepIndex: 1,
|
||||
onBackStep: uploadedFile
|
||||
? () => {
|
||||
@ -58,8 +60,8 @@ export const SinglePlayerClient = () => {
|
||||
onNextStep: () => setStep('sign'),
|
||||
},
|
||||
sign: {
|
||||
title: 'Sign',
|
||||
description: 'Enter your details.',
|
||||
title: msg`Sign`,
|
||||
description: msg`Enter your details.`,
|
||||
stepIndex: 2,
|
||||
onBackStep: () => setStep('fields'),
|
||||
},
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
|
||||
import { AxiomWebVitals } from 'next-axiom';
|
||||
import { PublicEnvScript } from 'next-runtime-env';
|
||||
|
||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { I18nClientProvider } from '@documenso/lib/client-only/providers/i18n.client';
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
|
||||
import type { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||
import { ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
||||
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -54,9 +59,29 @@ export function generateMetadata() {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const flags = await getAllAnonymousFlags();
|
||||
|
||||
let overrideLang: (typeof SUPPORTED_LANGUAGE_CODES)[number] | undefined;
|
||||
|
||||
// Should be safe to remove when we upgrade NextJS.
|
||||
// https://github.com/vercel/next.js/pull/65008
|
||||
// Currently if the middleware sets the cookie, it's not accessible in the cookies
|
||||
// during the same render.
|
||||
// So we go the roundabout way of checking the header for the set-cookie value.
|
||||
if (!cookies().get('i18n')) {
|
||||
const setCookieValue = headers().get('set-cookie');
|
||||
const i18nCookie = setCookieValue?.split(';').find((cookie) => cookie.startsWith('i18n='));
|
||||
|
||||
if (i18nCookie) {
|
||||
const i18n = i18nCookie.split('=')[1];
|
||||
|
||||
overrideLang = ZSupportedLanguageCodeSchema.parse(i18n);
|
||||
}
|
||||
}
|
||||
|
||||
const { lang, i18n } = setupI18nSSR(overrideLang);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang={lang}
|
||||
className={cn(fontInter.variable, fontCaveat.variable)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
@ -65,6 +90,7 @@ 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" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<PublicEnvScript />
|
||||
</head>
|
||||
|
||||
@ -78,7 +104,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<FeatureFlagProvider initialFlags={flags}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<PlausibleProvider>
|
||||
<TrpcProvider>{children}</TrpcProvider>
|
||||
<TrpcProvider>
|
||||
<I18nClientProvider initialLocale={lang} initialMessages={i18n.messages}>
|
||||
{children}
|
||||
</I18nClientProvider>
|
||||
</TrpcProvider>
|
||||
</PlausibleProvider>
|
||||
</ThemeProvider>
|
||||
</FeatureFlagProvider>
|
||||
|
||||
@ -26,6 +26,10 @@ export default function NotFound() {
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-100 lg:scale-[100%]"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||
}}
|
||||
priority
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
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';
|
||||
@ -13,16 +15,20 @@ export const CallToAction = ({ className, utmSource = 'generic-cta' }: CallToAct
|
||||
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>
|
||||
<h2 className="text-center text-2xl font-bold">
|
||||
<Trans>Join the Open Signing Movement</Trans>
|
||||
</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.
|
||||
<Trans>
|
||||
Create your account and start using state-of-the-art document signing. Open and
|
||||
beautiful signing is within your grasp.
|
||||
</Trans>
|
||||
</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
|
||||
<Trans>Get started</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
import { LuGithub } from 'react-icons/lu';
|
||||
|
||||
@ -15,23 +16,6 @@ export type CalloutProps = {
|
||||
export const Callout = ({ starCount }: CalloutProps) => {
|
||||
const event = usePlausible();
|
||||
|
||||
const onSignUpClick = () => {
|
||||
const el = document.getElementById('email');
|
||||
|
||||
if (el) {
|
||||
const { top } = el.getBoundingClientRect();
|
||||
|
||||
window.scrollTo({
|
||||
top: top - 120,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
el.focus();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
@ -40,9 +24,9 @@ export const Callout = ({ starCount }: CalloutProps) => {
|
||||
variant="outline"
|
||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
||||
>
|
||||
Try our Free Plan
|
||||
<Trans>Try our Free Plan</Trans>
|
||||
<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
|
||||
<Trans>No Credit Card required</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@ -2,8 +2,14 @@
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { AutoplayType } from 'embla-carousel-autoplay';
|
||||
import Autoplay from 'embla-carousel-autoplay';
|
||||
import useEmblaCarousel from 'embla-carousel-react';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Card } from '@documenso/ui/primitives/card';
|
||||
@ -13,13 +19,13 @@ import { Slide } from './slide';
|
||||
|
||||
const SLIDES = [
|
||||
{
|
||||
label: 'Signing Process',
|
||||
label: msg`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',
|
||||
label: msg`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',
|
||||
@ -31,7 +37,7 @@ const SLIDES = [
|
||||
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/zapier.webm',
|
||||
},
|
||||
{
|
||||
label: 'Direct Link',
|
||||
label: msg`Direct Link`,
|
||||
type: 'video',
|
||||
srcLight: 'https://github.com/documenso/design/raw/main/marketing/direct-links.webm',
|
||||
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/direct-links.webm',
|
||||
@ -49,7 +55,7 @@ const SLIDES = [
|
||||
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/api.webm',
|
||||
},
|
||||
{
|
||||
label: 'Profile',
|
||||
label: msg`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',
|
||||
@ -57,6 +63,9 @@ const SLIDES = [
|
||||
];
|
||||
|
||||
export const Carousel = () => {
|
||||
const { _ } = useLingui();
|
||||
const event = usePlausible();
|
||||
|
||||
const slides = SLIDES;
|
||||
const [_isPlaying, setIsPlaying] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
@ -73,6 +82,7 @@ export const Carousel = () => {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [
|
||||
Autoplay({ playOnInit: true, delay: autoplayDelay[selectedIndex] || 5000 }),
|
||||
]);
|
||||
|
||||
const [emblaThumbsRef, emblaThumbsApi] = useEmblaCarousel(
|
||||
{
|
||||
loop: true,
|
||||
@ -84,19 +94,28 @@ export const Carousel = () => {
|
||||
|
||||
const onThumbClick = useCallback(
|
||||
(index: number) => {
|
||||
if (!emblaApi || !emblaThumbsApi) return;
|
||||
if (!emblaApi || !emblaThumbsApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
emblaApi.scrollTo(index);
|
||||
},
|
||||
[emblaApi, emblaThumbsApi],
|
||||
);
|
||||
|
||||
const onSelect = useCallback(() => {
|
||||
if (!emblaApi || !emblaThumbsApi) return;
|
||||
if (!emblaApi || !emblaThumbsApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedIndex(emblaApi.selectedScrollSnap());
|
||||
emblaThumbsApi.scrollTo(emblaApi.selectedScrollSnap());
|
||||
|
||||
resetProgress();
|
||||
const autoplay = emblaApi.plugins()?.autoplay;
|
||||
|
||||
// moduleResolution: bundler breaks this type
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const autoplay = emblaApi.plugins()?.autoplay as unknown as AutoplayType | undefined;
|
||||
|
||||
if (autoplay) {
|
||||
autoplay.reset();
|
||||
@ -167,11 +186,18 @@ export const Carousel = () => {
|
||||
}, [emblaApi, onSelect, mounted, resolvedTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const autoplay = emblaApi?.plugins()?.autoplay;
|
||||
if (!autoplay) return;
|
||||
// moduleResolution: bundler breaks this type
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const autoplay = emblaApi?.plugins()?.autoplay as unknown as AutoplayType | undefined;
|
||||
|
||||
if (!autoplay || !emblaApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPlaying(autoplay.isPlaying());
|
||||
emblaApi
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(emblaApi as unknown as any)
|
||||
.on('autoplay:play', () => setIsPlaying(true))
|
||||
.on('autoplay:stop', () => setIsPlaying(false))
|
||||
.on('reInit', () => setIsPlaying(autoplay.isPlaying()));
|
||||
@ -216,7 +242,10 @@ export const Carousel = () => {
|
||||
if (!mounted) return null;
|
||||
return (
|
||||
<>
|
||||
<Card className="mx-auto mt-12 w-full max-w-4xl rounded-2xl p-1 before:rounded-2xl" gradient>
|
||||
<Card
|
||||
className="relative 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) => (
|
||||
@ -233,7 +262,7 @@ export const Carousel = () => {
|
||||
src={resolvedTheme === 'dark' ? slide.srcDark : slide.srcLight}
|
||||
type="video/webm"
|
||||
/>
|
||||
Your browser does not support the video tag.
|
||||
<Trans>Your browser does not support the video tag.</Trans>
|
||||
</video>
|
||||
)}
|
||||
</div>
|
||||
@ -247,6 +276,19 @@ export const Carousel = () => {
|
||||
</span>
|
||||
<Progress value={progress} className="h-1" />
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="https://documen.so/book-a-demo"
|
||||
className="bg-foreground/70 dark:bg-foreground/80 absolute inset-0 hidden flex-col items-center justify-center gap-y-2 rounded-xl opacity-0 backdrop-blur-[2px] transition-opacity group-hover:opacity-100 md:flex"
|
||||
onClick={() => event('view-demo')}
|
||||
>
|
||||
<span className="text-background max-w-[60ch] text-2xl font-semibold">Book a Demo</span>
|
||||
<span className="text-background max-w-[60ch] text-center text-sm">
|
||||
Want to learn more about Documenso and how it works? Book a demo today! Our founders
|
||||
will walk you through the application and answer any questions you may have regarding
|
||||
usage, integration, and more.
|
||||
</span>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<div className="mx-auto mt-6 w-full max-w-4xl px-2 sm:mt-12">
|
||||
@ -257,7 +299,7 @@ export const Carousel = () => {
|
||||
onClick={() => onThumbClick(index)}
|
||||
selected={index === selectedIndex}
|
||||
index={index}
|
||||
label={slide.label}
|
||||
label={typeof slide.label === 'string' ? slide.label : _(slide.label)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -12,13 +13,15 @@ export const Enterprise = () => {
|
||||
return (
|
||||
<div className="mx-auto mt-36 max-w-2xl">
|
||||
<h2 className="text-center text-2xl font-semibold">
|
||||
Enterprise Compliance, License or Technical Needs?
|
||||
<Trans>Enterprise Compliance, License or Technical Needs?</Trans>
|
||||
</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.
|
||||
<Trans>
|
||||
Our Enterprise License is great for large organizations looking to switch to Documenso for
|
||||
all their signing needs. It's available for our cloud offering as well as self-hosted
|
||||
setups and offers a wide range of compliance and Adminstration Features.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
@ -28,7 +31,9 @@ export const Enterprise = () => {
|
||||
className="mt-6"
|
||||
onClick={() => event('enterprise-contact')}
|
||||
>
|
||||
<Button className="rounded-full text-base">Contact Us</Button>
|
||||
<Button className="rounded-full text-base">
|
||||
<Trans>Contact Us</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,8 @@ import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||
import cardBeautifulFigure from '@documenso/assets/images/card-beautiful-figure.png';
|
||||
import cardFastFigure from '@documenso/assets/images/card-fast-figure.png';
|
||||
@ -22,20 +24,30 @@ export const FasterSmarterBeautifulBento = ({
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
||||
A 10x better signing experience.
|
||||
<span className="block md:mt-0">Faster, smarter and more beautiful.</span>
|
||||
<Trans>A 10x better signing experience.</Trans>
|
||||
<span className="block md:mt-0">
|
||||
<Trans>Faster, smarter and more beautiful.</Trans>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
||||
<Card className="col-span-2" degrees={45} gradient>
|
||||
<CardContent className="grid grid-cols-12 gap-8 overflow-hidden p-6 lg:aspect-[2.5/1]">
|
||||
<p className="text-foreground/80 col-span-12 leading-relaxed lg:col-span-6">
|
||||
<strong className="block">Fast.</strong>
|
||||
When it comes to sending or receiving a contract, you can count on lightning-fast
|
||||
speeds.
|
||||
<strong className="block">
|
||||
<Trans>Fast.</Trans>
|
||||
</strong>
|
||||
<Trans>
|
||||
When it comes to sending or receiving a contract, you can count on lightning-fast
|
||||
speeds.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
|
||||
@ -51,9 +63,13 @@ export const FasterSmarterBeautifulBento = ({
|
||||
<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">Beautiful.</strong>
|
||||
Because signing should be celebrated. That’s why we care about the smallest detail in
|
||||
our product.
|
||||
<strong className="block">
|
||||
<Trans>Beautiful.</Trans>
|
||||
</strong>
|
||||
<Trans>
|
||||
Because signing should be celebrated. That’s why we care about the smallest detail
|
||||
in our product.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center p-8">
|
||||
@ -69,8 +85,12 @@ export const FasterSmarterBeautifulBento = ({
|
||||
<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">Smart.</strong>
|
||||
Our custom templates come with smart rules that can help you save time and energy.
|
||||
<strong className="block">
|
||||
<Trans>Smart.</Trans>
|
||||
</strong>
|
||||
<Trans>
|
||||
Our custom templates come with smart rules that can help you save time and energy.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center p-8">
|
||||
|
||||
@ -5,6 +5,8 @@ import type { HTMLAttributes } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { FaXTwitter } from 'react-icons/fa6';
|
||||
import { LiaDiscord } from 'react-icons/lia';
|
||||
import { LuGithub } from 'react-icons/lu';
|
||||
@ -13,6 +15,8 @@ import LogoImage from '@documenso/assets/logo.png';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||
|
||||
import { I18nSwitcher } from '~/components/(marketing)/i18n-switcher';
|
||||
|
||||
// import { StatusWidgetContainer } from './status-widget-container';
|
||||
|
||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||
@ -24,22 +28,24 @@ const SOCIAL_LINKS = [
|
||||
];
|
||||
|
||||
const FOOTER_LINKS = [
|
||||
{ href: '/pricing', text: 'Pricing' },
|
||||
{ href: '/pricing', text: msg`Pricing` },
|
||||
{ href: '/singleplayer', text: 'Singleplayer' },
|
||||
{ href: 'https://docs.documenso.com', text: 'Documentation', target: '_blank' },
|
||||
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
|
||||
{ href: '/blog', text: 'Blog' },
|
||||
{ href: '/changelog', text: 'Changelog' },
|
||||
{ href: '/open', text: 'Open Startup' },
|
||||
{ href: '/design-system', text: 'Design' },
|
||||
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
||||
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
|
||||
{ href: '/oss-friends', text: 'OSS Friends' },
|
||||
{ href: '/careers', text: 'Careers' },
|
||||
{ href: '/privacy', text: 'Privacy' },
|
||||
{ href: 'https://docs.documenso.com', text: msg`Documentation`, target: '_blank' },
|
||||
{ href: 'mailto:support@documenso.com', text: msg`Support`, target: '_blank' },
|
||||
{ href: '/blog', text: msg`Blog` },
|
||||
{ href: '/changelog', text: msg`Changelog` },
|
||||
{ href: '/open', text: msg`Open Startup` },
|
||||
{ href: '/design-system', text: msg`Design` },
|
||||
{ href: 'https://shop.documenso.com', text: msg`Shop`, target: '_blank' },
|
||||
{ href: 'https://status.documenso.com', text: msg`Status`, target: '_blank' },
|
||||
{ href: '/oss-friends', text: msg`OSS Friends` },
|
||||
{ href: '/careers', text: msg`Careers` },
|
||||
{ href: '/privacy', text: msg`Privacy` },
|
||||
];
|
||||
|
||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<div className={cn('border-t py-12', className)} {...props}>
|
||||
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
||||
@ -80,7 +86,7 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
target={link.target}
|
||||
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 break-words text-sm"
|
||||
>
|
||||
{link.text}
|
||||
{typeof link.text === 'string' ? link.text : _(link.text)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@ -90,8 +96,12 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap">
|
||||
<ThemeSwitcher />
|
||||
<div className="flex flex-row-reverse items-center sm:flex-row">
|
||||
<I18nSwitcher className="text-muted-foreground ml-2 rounded-full font-normal sm:mr-2" />
|
||||
|
||||
<div className="flex flex-wrap">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -6,8 +6,9 @@ import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
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';
|
||||
|
||||
@ -19,10 +20,6 @@ export type HeaderProps = HTMLAttributes<HTMLElement>;
|
||||
export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const isSinglePlayerModeMarketingEnabled = getFlag('marketing_header_single_player_mode');
|
||||
|
||||
return (
|
||||
<header className={cn('flex items-center justify-between', className)} {...props}>
|
||||
<div className="flex items-center space-x-4">
|
||||
@ -35,15 +32,6 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
height={25}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{isSinglePlayerModeMarketingEnabled && (
|
||||
<Link
|
||||
href="/singleplayer"
|
||||
className="bg-primary dark:text-background rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
|
||||
>
|
||||
Try now!
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-x-6 md:flex">
|
||||
@ -51,7 +39,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
href="/pricing"
|
||||
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||
>
|
||||
Pricing
|
||||
<Trans>Pricing</Trans>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
@ -59,21 +47,21 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||
target="_blank"
|
||||
>
|
||||
Documentation
|
||||
<Trans>Documentation</Trans>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/blog"
|
||||
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||
>
|
||||
Blog
|
||||
<Trans>Blog</Trans>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/open"
|
||||
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||
>
|
||||
Open Startup
|
||||
<Trans>Open Startup</Trans>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
@ -81,12 +69,12 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
target="_blank"
|
||||
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||
>
|
||||
Sign in
|
||||
<Trans>Sign in</Trans>
|
||||
</Link>
|
||||
|
||||
<Button className="rounded-full" size="sm" asChild>
|
||||
<Link href="https://app.documenso.com/signup?utm_source=marketing-header" target="_blank">
|
||||
Sign up
|
||||
<Trans>Sign up</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import type { Variants } from 'framer-motion';
|
||||
import { motion } from 'framer-motion';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
@ -85,6 +86,10 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="-mr-[50vw] -mt-[15vh] h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
@ -96,8 +101,11 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
animate="animate"
|
||||
className="text-center text-4xl font-bold leading-tight tracking-tight md:text-[48px] lg:text-[64px]"
|
||||
>
|
||||
Document signing,
|
||||
<span className="block" /> finally open source.
|
||||
<Trans>
|
||||
Document signing,
|
||||
<span className="block" />
|
||||
finally open source.
|
||||
</Trans>
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
@ -112,39 +120,21 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
variant="outline"
|
||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
||||
>
|
||||
Try our Free Plan
|
||||
<Trans>Try our Free Plan</Trans>
|
||||
<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
|
||||
<Trans>No Credit Card required</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<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
|
||||
<Trans>Star on GitHub</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{match(heroMarketingCTA)
|
||||
.with('spm', () => (
|
||||
<motion.div
|
||||
variants={HeroTitleVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition-colors duration-300"
|
||||
>
|
||||
<Link href="/singleplayer" className="block px-4 py-2 text-center">
|
||||
<h2 className="text-muted-foreground text-xs font-semibold">
|
||||
Introducing Single Player Mode
|
||||
</h2>
|
||||
|
||||
<h1 className="text-foreground mt-1.5 font-medium leading-5">
|
||||
Self sign for free!
|
||||
</h1>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))
|
||||
.with('productHunt', () => (
|
||||
<motion.div
|
||||
variants={HeroTitleVariants}
|
||||
|
||||
71
apps/marketing/src/components/(marketing)/i18n-switcher.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
import { LuLanguages } from 'react-icons/lu';
|
||||
|
||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||
import { switchI18NLanguage } from '@documenso/lib/server-only/i18n/switch-i18n-language';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
|
||||
type I18nSwitcherProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const I18nSwitcher = ({ className }: I18nSwitcherProps) => {
|
||||
const { i18n, _ } = useLingui();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState(i18n.locale);
|
||||
|
||||
const setLanguage = async (lang: string) => {
|
||||
setValue(lang);
|
||||
setOpen(false);
|
||||
|
||||
await dynamicActivate(i18n, lang);
|
||||
await switchI18NLanguage(lang);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button className={className} variant="ghost" onClick={() => setOpen(true)}>
|
||||
<LuLanguages className="mr-1.5 h-4 w-4" />
|
||||
{SUPPORTED_LANGUAGES[value]?.full || i18n.locale}
|
||||
</Button>
|
||||
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder={_(msg`Search languages...`)} />
|
||||
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{Object.values(SUPPORTED_LANGUAGES).map((language) => (
|
||||
<CommandItem
|
||||
key={language.short}
|
||||
value={language.full}
|
||||
onSelect={async () => setLanguage(language.short)}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
value === language.short ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{SUPPORTED_LANGUAGES[language.short].full}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -3,6 +3,8 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { FaXTwitter } from 'react-icons/fa6';
|
||||
import { LiaDiscord } from 'react-icons/lia';
|
||||
@ -19,11 +21,11 @@ export type MobileNavigationProps = {
|
||||
export const MENU_NAVIGATION_LINKS = [
|
||||
{
|
||||
href: '/pricing',
|
||||
text: 'Pricing',
|
||||
text: msg`Pricing`,
|
||||
},
|
||||
{
|
||||
href: 'https://documen.so/docs-nav',
|
||||
text: 'Documentation',
|
||||
text: msg`Documentation`,
|
||||
},
|
||||
{
|
||||
href: '/singleplayer',
|
||||
@ -31,36 +33,38 @@ export const MENU_NAVIGATION_LINKS = [
|
||||
},
|
||||
{
|
||||
href: '/blog',
|
||||
text: 'Blog',
|
||||
text: msg`Blog`,
|
||||
},
|
||||
{
|
||||
href: '/open',
|
||||
text: 'Open Startup',
|
||||
text: msg`Open Startup`,
|
||||
},
|
||||
{
|
||||
href: 'https://status.documenso.com',
|
||||
text: 'Status',
|
||||
text: msg`Status`,
|
||||
},
|
||||
{
|
||||
href: 'mailto:support@documenso.com',
|
||||
text: 'Support',
|
||||
text: msg`Support`,
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
href: '/privacy',
|
||||
text: 'Privacy',
|
||||
text: msg`Privacy`,
|
||||
},
|
||||
{
|
||||
href: 'https://app.documenso.com/signup?utm_source=marketing-header',
|
||||
text: 'Sign up',
|
||||
text: msg`Sign up`,
|
||||
},
|
||||
{
|
||||
href: 'https://app.documenso.com/signin?utm_source=marketing-header',
|
||||
text: 'Sign in',
|
||||
text: msg`Sign in`,
|
||||
},
|
||||
];
|
||||
|
||||
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const handleMenuItemClick = () => {
|
||||
@ -112,7 +116,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
onClick={() => handleMenuItemClick()}
|
||||
target={target}
|
||||
>
|
||||
{text}
|
||||
{typeof text === 'string' ? text : _(text)}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
@ -2,6 +2,8 @@ import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||
import cardBuildFigure from '@documenso/assets/images/card-build-figure.png';
|
||||
import cardOpenFigure from '@documenso/assets/images/card-open-figure.png';
|
||||
@ -19,20 +21,30 @@ export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplat
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
||||
Truly your own.
|
||||
<span className="block md:mt-0">Customise and expand.</span>
|
||||
<Trans>Truly your own.</Trans>
|
||||
<span className="block md:mt-0">
|
||||
<Trans>Customise and expand.</Trans>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
||||
<Card className="col-span-2" degrees={45} gradient>
|
||||
<CardContent className="grid grid-cols-12 gap-8 overflow-hidden p-6 lg:aspect-[2.5/1]">
|
||||
<p className="text-foreground/80 col-span-12 leading-relaxed lg:col-span-6">
|
||||
<strong className="block">Open Source or Hosted.</strong>
|
||||
It’s up to you. Either clone our repository or rely on our easy to use hosting
|
||||
solution.
|
||||
<strong className="block">
|
||||
<Trans>Open Source or Hosted.</Trans>
|
||||
</strong>
|
||||
<Trans>
|
||||
It’s up to you. Either clone our repository or rely on our easy to use hosting
|
||||
solution.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
|
||||
@ -48,8 +60,10 @@ export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplat
|
||||
<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">Build on top.</strong>
|
||||
Make it your own through advanced customization and adjustability.
|
||||
<strong className="block">
|
||||
<Trans>Build on top.</Trans>
|
||||
</strong>
|
||||
<Trans>Make it your own through advanced customization and adjustability.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center p-8">
|
||||
@ -65,9 +79,13 @@ export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplat
|
||||
<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">Template Store (Soon).</strong>
|
||||
Choose a template from the community app store. Or submit your own template for others
|
||||
to use.
|
||||
<strong className="block">
|
||||
<Trans>Template Store (Soon).</Trans>
|
||||
</strong>
|
||||
<Trans>
|
||||
Choose a template from the community app store. Or submit your own template for
|
||||
others to use.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center p-8">
|
||||
|
||||
@ -5,6 +5,7 @@ import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
|
||||
@ -36,7 +37,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
)}
|
||||
onClick={() => setPeriod('MONTHLY')}
|
||||
>
|
||||
Monthly
|
||||
<Trans>Monthly</Trans>
|
||||
{period === 'MONTHLY' && (
|
||||
<motion.div
|
||||
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
|
||||
@ -56,9 +57,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
)}
|
||||
onClick={() => setPeriod('YEARLY')}
|
||||
>
|
||||
Yearly
|
||||
<Trans>Yearly</Trans>
|
||||
<div className="bg-muted text-foreground block rounded-full px-2 py-0.5 text-xs">
|
||||
Save $60 or $120
|
||||
<Trans>Save $60 or $120</Trans>
|
||||
</div>
|
||||
{period === 'YEARLY' && (
|
||||
<motion.div
|
||||
@ -75,11 +76,13 @@ 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">
|
||||
<Trans>Free</Trans>
|
||||
</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">
|
||||
For small teams and individuals with basic needs.
|
||||
<Trans>For small teams and individuals with basic needs.</Trans>
|
||||
</p>
|
||||
|
||||
<Button className="rounded-full text-base" asChild>
|
||||
@ -88,14 +91,20 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
target="_blank"
|
||||
className="mt-6"
|
||||
>
|
||||
Signup Now
|
||||
<Trans>Signup Now</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="mt-8 flex w-full flex-col divide-y">
|
||||
<p className="text-foreground py-4">5 standard documents per month</p>
|
||||
<p className="text-foreground py-4">Up to 10 recipients per document</p>
|
||||
<p className="text-foreground py-4">No credit card required</p>
|
||||
<p className="text-foreground py-4">
|
||||
<Trans>5 standard documents per month</Trans>
|
||||
</p>
|
||||
<p className="text-foreground py-4">
|
||||
<Trans>Up to 10 recipients per document</Trans>
|
||||
</p>
|
||||
<p className="text-foreground py-4">
|
||||
<Trans>No credit card required</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
@ -105,7 +114,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
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]"
|
||||
>
|
||||
<p className="text-foreground text-4xl font-medium">Individual</p>
|
||||
<p className="text-foreground text-4xl font-medium">
|
||||
<Trans>Individual</Trans>
|
||||
</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,7 +125,7 @@ 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.
|
||||
<Trans>Everything you need for a great signing experience.</Trans>
|
||||
</p>
|
||||
|
||||
<Button className="mt-6 rounded-full text-base" asChild>
|
||||
@ -122,15 +133,23 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-individual-plan`}
|
||||
target="_blank"
|
||||
>
|
||||
Signup Now
|
||||
<Trans>Signup Now</Trans>
|
||||
</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">
|
||||
<Trans>Unlimited Documents per Month</Trans>
|
||||
</p>
|
||||
<p className="text-foreground py-4">
|
||||
<Trans>API Access</Trans>
|
||||
</p>
|
||||
<p className="text-foreground py-4">
|
||||
<Trans>Email and Discord Support</Trans>
|
||||
</p>
|
||||
<p className="text-foreground py-4">
|
||||
<Trans>Premium Profile Name</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
@ -139,7 +158,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
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"
|
||||
>
|
||||
<p className="text-foreground text-4xl font-medium">Teams</p>
|
||||
<p className="text-foreground text-4xl font-medium">
|
||||
<Trans>Teams</Trans>
|
||||
</p>
|
||||
<div className="text-primary mt-2.5 text-xl font-medium">
|
||||
<AnimatePresence mode="wait">
|
||||
{period === 'MONTHLY' && <motion.div layoutId="pricingTeams">$50</motion.div>}
|
||||
@ -148,7 +169,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
</div>
|
||||
|
||||
<p className="text-foreground mt-4 max-w-[30ch] text-center">
|
||||
For companies looking to scale across multiple teams.
|
||||
<Trans>For companies looking to scale across multiple teams.</Trans>
|
||||
</p>
|
||||
|
||||
<Button className="mt-6 rounded-full text-base" asChild>
|
||||
@ -156,18 +177,28 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-teams-plan`}
|
||||
target="_blank"
|
||||
>
|
||||
Signup Now
|
||||
<Trans>Signup Now</Trans>
|
||||
</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 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 {period === 'MONTHLY' ? '$10/ mo.' : '$96/ yr.'}
|
||||
<Trans>Unlimited Documents per Month</Trans>
|
||||
</p>
|
||||
<p className="text-foreground py-4">
|
||||
<Trans>API Access</Trans>
|
||||
</p>
|
||||
<p className="text-foreground py-4">
|
||||
<Trans>Email and Discord Support</Trans>
|
||||
</p>
|
||||
<p className="text-foreground py-4 font-medium">
|
||||
<Trans>Team Inbox</Trans>
|
||||
</p>
|
||||
<p className="text-foreground py-4">
|
||||
<Trans>5 Users Included</Trans>
|
||||
</p>
|
||||
<p className="text-foreground py-4">
|
||||
<Trans>Add More Users for {period === 'MONTHLY' ? '$10/ mo.' : '$96/ yr.'}</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,8 @@ import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||
import cardConnectionsFigure from '@documenso/assets/images/card-connections-figure.png';
|
||||
import cardPaidFigure from '@documenso/assets/images/card-paid-figure.png';
|
||||
@ -23,19 +25,27 @@ export const ShareConnectPaidWidgetBento = ({
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
||||
Integrates with all your favourite tools.
|
||||
<span className="block md:mt-0">Send, connect, receive and embed everywhere.</span>
|
||||
<Trans>Integrates with all your favourite tools.</Trans>
|
||||
<span className="block md:mt-0">
|
||||
<Trans>Send, connect, receive and embed everywhere.</Trans>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
||||
<Card className="col-span-2 lg:col-span-1" degrees={120} gradient>
|
||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||
<p className="text-foreground/80 leading-relaxed">
|
||||
<strong className="block">Easy Sharing (Soon).</strong>
|
||||
Receive your personal link to share with everyone you care about.
|
||||
<strong className="block">
|
||||
<Trans>Easy Sharing.</Trans>
|
||||
</strong>
|
||||
<Trans>Receive your personal link to share with everyone you care about.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center p-8">
|
||||
@ -51,9 +61,13 @@ 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>
|
||||
Create connections and automations with Zapier and more to integrate with your
|
||||
favorite tools.
|
||||
<strong className="block">
|
||||
<Trans>Connections</Trans>
|
||||
</strong>
|
||||
<Trans>
|
||||
Create connections and automations with Zapier and more to integrate with your
|
||||
favorite tools.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center p-8">
|
||||
@ -69,8 +83,12 @@ 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">Get paid (Soon).</strong>
|
||||
Integrated payments with Stripe so you don’t have to worry about getting paid.
|
||||
<strong className="block">
|
||||
<Trans>Get paid (Soon).</Trans>
|
||||
</strong>
|
||||
<Trans>
|
||||
Integrated payments with Stripe so you don’t have to worry about getting paid.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center p-8">
|
||||
@ -86,9 +104,13 @@ 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">React Widget (Soon).</strong>
|
||||
Easily embed Documenso into your product. Simply copy and paste our react widget into
|
||||
your application.
|
||||
<strong className="block">
|
||||
<Trans>React Widget (Soon).</Trans>
|
||||
</strong>
|
||||
<Trans>
|
||||
Easily embed Documenso into your product. Simply copy and paste our react widget
|
||||
into your application.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center p-8">
|
||||
|
||||
39
apps/marketing/src/middleware.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { extractSupportedLanguage } from '@documenso/lib/utils/i18n';
|
||||
|
||||
export default function middleware(req: NextRequest) {
|
||||
const lang = extractSupportedLanguage({
|
||||
headers: req.headers,
|
||||
cookies: cookies(),
|
||||
});
|
||||
|
||||
const response = NextResponse.next();
|
||||
|
||||
response.cookies.set('i18n', lang);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - ingest (analytics)
|
||||
* - site.webmanifest
|
||||
*/
|
||||
{
|
||||
source: '/((?!api|_next/static|_next/image|ingest|favicon|site.webmanifest).*)',
|
||||
missing: [
|
||||
{ type: 'header', key: 'next-router-prefetch' },
|
||||
{ type: 'header', key: 'purpose', value: 'prefetch' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
22
apps/web/lingui.config.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { LinguiConfig } from '@lingui/conf';
|
||||
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
|
||||
// Extends root lingui.config.cjs.
|
||||
const config: LinguiConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
locales: APP_I18N_OPTIONS.supportedLangs as unknown as string[],
|
||||
catalogs: [
|
||||
{
|
||||
path: '<rootDir>/../../packages/lib/translations/{locale}/web',
|
||||
include: ['<rootDir>/apps/web/src'],
|
||||
},
|
||||
{
|
||||
path: '<rootDir>/../../packages/lib/translations/{locale}/common',
|
||||
include: ['<rootDir>/packages/ui', '<rootDir>/packages/lib'],
|
||||
},
|
||||
],
|
||||
catalogsMergePath: '<rootDir>/../../packages/lib/translations/{locale}/web',
|
||||
};
|
||||
|
||||
export default config;
|
||||
@ -31,6 +31,7 @@ const config = {
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
swcPlugins: [['@lingui/swc-plugin', {}]],
|
||||
},
|
||||
reactStrictMode: true,
|
||||
transpilePackages: [
|
||||
@ -59,6 +60,13 @@ const config = {
|
||||
config.resolve.alias.canvas = false;
|
||||
}
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.po$/,
|
||||
use: {
|
||||
loader: '@lingui/loader',
|
||||
},
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
async rewrites() {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/web",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0-rc.2",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -11,7 +11,8 @@
|
||||
"e2e:prepare": "next build && next start",
|
||||
"lint:fix": "next lint --fix",
|
||||
"clean": "rimraf .next && rimraf node_modules",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs",
|
||||
"translate:compile": "lingui compile --typescript"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
@ -22,6 +23,7 @@
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@lingui/react": "^4.11.1",
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
@ -58,6 +60,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@lingui/loader": "^4.11.1",
|
||||
"@lingui/swc-plugin": "4.0.6",
|
||||
"@simplewebauthn/types": "^9.0.1",
|
||||
"@types/formidable": "^2.0.6",
|
||||
"@types/luxon": "^3.3.1",
|
||||
|
||||
2
apps/web/process-env.d.ts
vendored
@ -16,5 +16,7 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_OIDC_WELL_KNOWN: string;
|
||||
NEXT_PRIVATE_OIDC_CLIENT_ID: string;
|
||||
NEXT_PRIVATE_OIDC_CLIENT_SECRET: string;
|
||||
NEXT_PRIVATE_OIDC_ALLOW_SIGNUP?: string;
|
||||
NEXT_PRIVATE_OIDC_SKIP_VERIFY?: string;
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/web/public/static/delete-team.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
apps/web/public/static/delete-user.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
@ -2,6 +2,9 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { type Document, SigningStatus } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -22,20 +25,21 @@ export type AdminActionsProps = {
|
||||
};
|
||||
|
||||
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
|
||||
trpc.admin.resealDocument.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Document resealed',
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Document resealed`),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to reseal document',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to reseal document`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
@ -54,19 +58,23 @@ export const AdminActions = ({ className, document, recipients }: AdminActionsPr
|
||||
)}
|
||||
onClick={() => resealDocument({ id: document.id })}
|
||||
>
|
||||
Reseal document
|
||||
<Trans>Reseal document</Trans>
|
||||
</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.
|
||||
<Trans>
|
||||
Attempts sealing the document again, useful for after a code change has occurred to
|
||||
resolve an erroneous document.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/users/${document.userId}`}>Go to owner</Link>
|
||||
<Link href={`/admin/users/${document.userId}`}>
|
||||
<Trans>Go to owner</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||
import {
|
||||
Accordion,
|
||||
@ -23,6 +25,8 @@ type AdminDocumentDetailsPageProps = {
|
||||
};
|
||||
|
||||
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
|
||||
setupI18nSSR();
|
||||
|
||||
const document = await getEntireDocument({ id: Number(params.id) });
|
||||
|
||||
return (
|
||||
@ -35,28 +39,34 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
|
||||
|
||||
{document.deletedAt && (
|
||||
<Badge size="large" variant="destructive">
|
||||
Deleted
|
||||
<Trans>Deleted</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-4 text-sm">
|
||||
<div>
|
||||
Created on: <LocaleDate date={document.createdAt} format={DateTime.DATETIME_MED} />
|
||||
<Trans>Created on</Trans>:{' '}
|
||||
<LocaleDate date={document.createdAt} format={DateTime.DATETIME_MED} />
|
||||
</div>
|
||||
<div>
|
||||
Last updated at: <LocaleDate date={document.updatedAt} format={DateTime.DATETIME_MED} />
|
||||
<Trans>Last updated at</Trans>:{' '}
|
||||
<LocaleDate date={document.updatedAt} format={DateTime.DATETIME_MED} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<h2 className="text-lg font-semibold">Admin Actions</h2>
|
||||
<h2 className="text-lg font-semibold">
|
||||
<Trans>Admin Actions</Trans>
|
||||
</h2>
|
||||
|
||||
<AdminActions className="mt-2" document={document} recipients={document.Recipient} />
|
||||
|
||||
<hr className="my-4" />
|
||||
<h2 className="text-lg font-semibold">Recipients</h2>
|
||||
<h2 className="text-lg font-semibold">
|
||||
<Trans>Recipients</Trans>
|
||||
</h2>
|
||||
|
||||
<div className="mt-4">
|
||||
<Accordion type="multiple" className="space-y-4">
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@ -13,6 +17,7 @@ import {
|
||||
} from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import {
|
||||
Form,
|
||||
@ -43,7 +48,9 @@ export type RecipientItemProps = {
|
||||
};
|
||||
|
||||
export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TAdminUpdateRecipientFormSchema>({
|
||||
@ -55,6 +62,50 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
||||
|
||||
const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||
},
|
||||
{
|
||||
header: _(msg`Type`),
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => <div>{row.original.type}</div>,
|
||||
},
|
||||
{
|
||||
header: _(msg`Inserted`),
|
||||
accessorKey: 'inserted',
|
||||
cell: ({ row }) => <div>{row.original.inserted ? 'True' : 'False'}</div>,
|
||||
},
|
||||
{
|
||||
header: _(msg`Value`),
|
||||
accessorKey: 'customText',
|
||||
cell: ({ row }) => <div>{row.original.customText}</div>,
|
||||
},
|
||||
{
|
||||
header: _(msg`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>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof recipient)['Field'][number]>[];
|
||||
}, []);
|
||||
|
||||
const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => {
|
||||
try {
|
||||
await updateRecipient({
|
||||
@ -64,14 +115,14 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Recipient updated',
|
||||
description: 'The recipient has been updated successfully',
|
||||
title: _(msg`Recipient updated`),
|
||||
description: _(msg`The recipient has been updated successfully`),
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to update recipient',
|
||||
title: _(msg`Failed to update recipient`),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@ -93,7 +144,9 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>Name</FormLabel>
|
||||
<FormLabel required>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
@ -109,7 +162,9 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>Email</FormLabel>
|
||||
<FormLabel required>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
@ -122,7 +177,7 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
||||
|
||||
<div>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
Update Recipient
|
||||
<Trans>Update Recipient</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
@ -131,52 +186,11 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<h2 className="mb-4 text-lg font-semibold">Fields</h2>
|
||||
<h2 className="mb-4 text-lg font-semibold">
|
||||
<Trans>Fields</Trans>
|
||||
</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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DataTable columns={columns} data={recipient.Field} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -4,6 +4,9 @@ import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import type { Document } from '@documenso/prisma/client';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -26,7 +29,9 @@ export type SuperDeleteDocumentDialogProps = {
|
||||
};
|
||||
|
||||
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [reason, setReason] = useState('');
|
||||
@ -43,7 +48,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
|
||||
await deleteDocument({ id: document.id, reason });
|
||||
|
||||
toast({
|
||||
title: 'Document deleted',
|
||||
title: _(msg`Document deleted`),
|
||||
description: 'The Document has been deleted successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
@ -52,13 +57,13 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
|
||||
} catch (err) {
|
||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||
toast({
|
||||
title: 'An error occurred',
|
||||
title: _(msg`An error occurred`),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
title: _(msg`An unknown error occurred`),
|
||||
variant: 'destructive',
|
||||
description:
|
||||
err.message ??
|
||||
@ -76,31 +81,41 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
|
||||
variant="neutral"
|
||||
>
|
||||
<div>
|
||||
<AlertTitle>Delete Document</AlertTitle>
|
||||
<AlertTitle>
|
||||
<Trans>Delete Document</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
Delete the document. This action is irreversible so proceed with caution.
|
||||
<Trans>
|
||||
Delete the document. This action is irreversible so proceed with caution.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">Delete Document</Button>
|
||||
<Button variant="destructive">
|
||||
<Trans>Delete Document</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>Delete Document</DialogTitle>
|
||||
<DialogTitle>
|
||||
<Trans>Delete Document</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
This action is not reversible. Please be certain.
|
||||
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<DialogDescription>To confirm, please enter the reason</DialogDescription>
|
||||
<DialogDescription>
|
||||
<Trans>To confirm, please enter the reason</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Input
|
||||
className="mt-2"
|
||||
@ -117,7 +132,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
|
||||
variant="destructive"
|
||||
disabled={!reason}
|
||||
>
|
||||
{isDeletingDocument ? 'Deleting document...' : 'Delete Document'}
|
||||
<Trans>Delete document</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
@ -12,6 +14,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
@ -23,6 +26,8 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
// export type AdminDocumentResultsProps = {};
|
||||
|
||||
export const AdminDocumentResults = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
@ -45,6 +50,83 @@ export const AdminDocumentResults = () => {
|
||||
},
|
||||
);
|
||||
|
||||
const results = findDocumentsData ?? {
|
||||
data: [],
|
||||
perPage: 20,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: _(msg`Created`),
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||
},
|
||||
{
|
||||
header: _(msg`Title`),
|
||||
accessorKey: 'title',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/documents/${row.original.id}`}
|
||||
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
|
||||
>
|
||||
{row.original.title}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: _(msg`Status`),
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
header: _(msg`Owner`),
|
||||
accessorKey: 'owner',
|
||||
cell: ({ row }) => {
|
||||
const avatarFallbackText = row.original.User.name
|
||||
? extractInitials(row.original.User.name)
|
||||
: row.original.User.email.slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger>
|
||||
<Link href={`/admin/users/${row.original.User.id}`}>
|
||||
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{avatarFallbackText}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="flex max-w-xs items-center gap-2">
|
||||
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{avatarFallbackText}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="text-muted-foreground flex flex-col text-sm">
|
||||
<span>{row.original.User.name}</span>
|
||||
<span>{row.original.User.email}</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Last updated',
|
||||
accessorKey: 'updatedAt',
|
||||
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
|
||||
const onPaginationChange = (newPage: number, newPerPage: number) => {
|
||||
updateSearchParams({
|
||||
page: newPage,
|
||||
@ -56,84 +138,18 @@ export const AdminDocumentResults = () => {
|
||||
<div>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by document title"
|
||||
placeholder={_(msg`Search by document title`)}
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="relative mt-4">
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
header: 'Created',
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||
},
|
||||
{
|
||||
header: 'Title',
|
||||
accessorKey: 'title',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/documents/${row.original.id}`}
|
||||
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
|
||||
>
|
||||
{row.original.title}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
header: 'Owner',
|
||||
accessorKey: 'owner',
|
||||
cell: ({ row }) => {
|
||||
const avatarFallbackText = row.original.User.name
|
||||
? extractInitials(row.original.User.name)
|
||||
: row.original.User.email.slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger>
|
||||
<Link href={`/admin/users/${row.original.User.id}`}>
|
||||
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{avatarFallbackText}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="flex max-w-xs items-center gap-2">
|
||||
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{avatarFallbackText}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="text-muted-foreground flex flex-col text-sm">
|
||||
<span>{row.original.User.name}</span>
|
||||
<span>{row.original.User.email}</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Last updated',
|
||||
accessorKey: 'updatedAt',
|
||||
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
|
||||
},
|
||||
]}
|
||||
data={findDocumentsData?.data ?? []}
|
||||
perPage={findDocumentsData?.perPage ?? 20}
|
||||
currentPage={findDocumentsData?.currentPage ?? 1}
|
||||
totalPages={findDocumentsData?.totalPages ?? 1}
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage ?? 20}
|
||||
currentPage={results.currentPage ?? 1}
|
||||
totalPages={results.totalPages ?? 1}
|
||||
onPaginationChange={onPaginationChange}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import { AdminDocumentResults } from './document-results';
|
||||
|
||||
export default function AdminDocumentsPage() {
|
||||
setupI18nSSR();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">Manage documents</h2>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Manage documents</Trans>
|
||||
</h2>
|
||||
|
||||
<div className="mt-8">
|
||||
<AdminDocumentResults />
|
||||
|
||||
@ -2,6 +2,7 @@ import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
|
||||
@ -12,6 +13,8 @@ export type AdminSectionLayoutProps = {
|
||||
};
|
||||
|
||||
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
|
||||
setupI18nSSR();
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
|
||||
@ -5,6 +5,7 @@ import type { HTMLAttributes } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { BarChart3, FileStack, Settings, Users, Wallet2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -33,7 +34,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||
>
|
||||
<Link href="/admin/stats">
|
||||
<BarChart3 className="mr-2 h-5 w-5" />
|
||||
Stats
|
||||
<Trans>Stats</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@ -47,7 +48,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||
>
|
||||
<Link href="/admin/users">
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
Users
|
||||
<Trans>Users</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@ -61,7 +62,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||
>
|
||||
<Link href="/admin/documents">
|
||||
<FileStack className="mr-2 h-5 w-5" />
|
||||
Documents
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@ -75,7 +76,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||
>
|
||||
<Link href="/admin/subscriptions">
|
||||
<Wallet2 className="mr-2 h-5 w-5" />
|
||||
Subscriptions
|
||||
<Trans>Subscriptions</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@ -89,7 +90,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||
>
|
||||
<Link href="/admin/site-settings">
|
||||
<Settings className="mr-2 h-5 w-5" />
|
||||
Site Settings
|
||||
<Trans>Site Settings</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
@ -37,8 +39,10 @@ export type BannerFormProps = {
|
||||
};
|
||||
|
||||
export function BannerForm({ banner }: BannerFormProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TBannerFormSchema>({
|
||||
resolver: zodResolver(ZBannerFormSchema),
|
||||
@ -67,8 +71,8 @@ export function BannerForm({ banner }: BannerFormProps) {
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Banner Updated',
|
||||
description: 'Your banner has been updated successfully.',
|
||||
title: _(msg`Banner Updated`),
|
||||
description: _(msg`Your banner has been updated successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@ -76,16 +80,17 @@ export function BannerForm({ banner }: BannerFormProps) {
|
||||
} catch (err) {
|
||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||
toast({
|
||||
title: 'An error occurred',
|
||||
title: _(msg`An error occurred`),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
title: _(msg`An unknown error occurred`),
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to update the banner. Please try again later.',
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -93,10 +98,14 @@ export function BannerForm({ banner }: BannerFormProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-semibold">Site Banner</h2>
|
||||
<h2 className="font-semibold">
|
||||
<Trans>Site Banner</Trans>
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
The site banner is a message that is shown at the top of the site. It can be used to display
|
||||
important information to your users.
|
||||
<Trans>
|
||||
The site banner is a message that is shown at the top of the site. It can be used to
|
||||
display important information to your users.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Form {...form}>
|
||||
@ -110,7 +119,9 @@ export function BannerForm({ banner }: BannerFormProps) {
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Enabled</FormLabel>
|
||||
<FormLabel>
|
||||
<Trans>Enabled</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
@ -131,7 +142,9 @@ export function BannerForm({ banner }: BannerFormProps) {
|
||||
name="data.bgColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Background Color</FormLabel>
|
||||
<FormLabel>
|
||||
<Trans>Background Color</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
@ -149,7 +162,9 @@ export function BannerForm({ banner }: BannerFormProps) {
|
||||
name="data.textColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Text Color</FormLabel>
|
||||
<FormLabel>
|
||||
<Trans>Text Color</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
@ -170,14 +185,16 @@ export function BannerForm({ banner }: BannerFormProps) {
|
||||
name="data.content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormLabel>
|
||||
<Trans>Content</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea className="h-32 resize-none" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
The content to show in the banner, HTML is allowed
|
||||
<Trans>The content to show in the banner, HTML is allowed</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
@ -191,7 +208,7 @@ export function BannerForm({ banner }: BannerFormProps) {
|
||||
loading={isUpdateSiteSettingLoading}
|
||||
className="mt-4 justify-end self-end"
|
||||
>
|
||||
Update Banner
|
||||
<Trans>Update Banner</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
||||
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||
|
||||
@ -8,13 +12,20 @@ import { BannerForm } from './banner-form';
|
||||
// import { BannerForm } from './banner-form';
|
||||
|
||||
export default async function AdminBannerPage() {
|
||||
setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
const banner = await getSiteSettings().then((settings) =>
|
||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title="Site Settings" subtitle="Manage your site settings here" />
|
||||
<SettingsHeader
|
||||
title={_(msg`Site Settings`)}
|
||||
subtitle={_(msg`Manage your site settings here`)}
|
||||
/>
|
||||
|
||||
<div className="mt-8">
|
||||
<BannerForm banner={banner} />
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import {
|
||||
File,
|
||||
FileCheck,
|
||||
@ -12,6 +14,7 @@ import {
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||
import {
|
||||
@ -27,6 +30,10 @@ import { SignerConversionChart } from './signer-conversion-chart';
|
||||
import { UserWithDocumentChart } from './user-with-document';
|
||||
|
||||
export default async function AdminStatsPage() {
|
||||
setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [
|
||||
usersCount,
|
||||
usersWithSubscriptionsCount,
|
||||
@ -49,64 +56,98 @@ export default async function AdminStatsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">Instance Stats</h2>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Instance Stats</Trans>
|
||||
</h2>
|
||||
|
||||
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<CardMetric icon={Users} title="Total Users" value={usersCount} />
|
||||
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
|
||||
<CardMetric icon={Users} title={_(msg`Total Users`)} value={usersCount} />
|
||||
<CardMetric icon={File} title={_(msg`Total Documents`)} value={docStats.ALL} />
|
||||
<CardMetric
|
||||
icon={UserPlus}
|
||||
title="Active Subscriptions"
|
||||
title={_(msg`Active Subscriptions`)}
|
||||
value={usersWithSubscriptionsCount}
|
||||
/>
|
||||
|
||||
<CardMetric icon={FileCog} title="App Version" value={`v${process.env.APP_VERSION}`} />
|
||||
<CardMetric
|
||||
icon={FileCog}
|
||||
title={_(msg`App Version`)}
|
||||
value={`v${process.env.APP_VERSION}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 gap-8">
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold">Document metrics</h3>
|
||||
<h3 className="text-3xl font-semibold">
|
||||
<Trans>Document metrics</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CardMetric icon={FileEdit} title="Drafted Documents" value={docStats.DRAFT} />
|
||||
<CardMetric icon={FileClock} title="Pending Documents" value={docStats.PENDING} />
|
||||
<CardMetric icon={FileCheck} title="Completed Documents" value={docStats.COMPLETED} />
|
||||
<CardMetric icon={FileEdit} title={_(msg`Drafted Documents`)} value={docStats.DRAFT} />
|
||||
<CardMetric
|
||||
icon={FileClock}
|
||||
title={_(msg`Pending Documents`)}
|
||||
value={docStats.PENDING}
|
||||
/>
|
||||
<CardMetric
|
||||
icon={FileCheck}
|
||||
title={_(msg`Completed Documents`)}
|
||||
value={docStats.COMPLETED}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
|
||||
<h3 className="text-3xl font-semibold">
|
||||
<Trans>Recipients metrics</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CardMetric
|
||||
icon={UserSquare2}
|
||||
title="Total Recipients"
|
||||
title={_(msg`Total Recipients`)}
|
||||
value={recipientStats.TOTAL_RECIPIENTS}
|
||||
/>
|
||||
<CardMetric icon={Mail} title="Documents Received" value={recipientStats.SENT} />
|
||||
<CardMetric icon={MailOpen} title="Documents Viewed" value={recipientStats.OPENED} />
|
||||
<CardMetric icon={PenTool} title="Signatures Collected" value={recipientStats.SIGNED} />
|
||||
<CardMetric
|
||||
icon={Mail}
|
||||
title={_(msg`Documents Received`)}
|
||||
value={recipientStats.SENT}
|
||||
/>
|
||||
<CardMetric
|
||||
icon={MailOpen}
|
||||
title={_(msg`Documents Viewed`)}
|
||||
value={recipientStats.OPENED}
|
||||
/>
|
||||
<CardMetric
|
||||
icon={PenTool}
|
||||
title={_(msg`Signatures Collected`)}
|
||||
value={recipientStats.SIGNED}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16">
|
||||
<h3 className="text-3xl font-semibold">Charts</h3>
|
||||
<h3 className="text-3xl font-semibold">
|
||||
<Trans>Charts</Trans>
|
||||
</h3>
|
||||
<div className="mt-5 grid grid-cols-2 gap-8">
|
||||
<UserWithDocumentChart
|
||||
data={MONTHLY_USERS_SIGNED}
|
||||
title="MAU (created document)"
|
||||
tooltip="Monthly Active Users: Users that created at least one Document"
|
||||
title={_(msg`MAU (created document)`)}
|
||||
tooltip={_(msg`Monthly Active Users: Users that created at least one Document`)}
|
||||
/>
|
||||
<UserWithDocumentChart
|
||||
data={MONTHLY_USERS_SIGNED}
|
||||
completed
|
||||
title="MAU (had document completed)"
|
||||
tooltip="Monthly Active Users: Users that had at least one of their documents completed"
|
||||
title={_(msg`MAU (had document completed)`)}
|
||||
tooltip={_(
|
||||
msg`Monthly Active Users: Users that had at least one of their documents completed`,
|
||||
)}
|
||||
/>
|
||||
<SignerConversionChart title="Signers that Signed Up" data={signerConversionMonthly} />
|
||||
<SignerConversionChart
|
||||
title="Total Signers that Signed Up"
|
||||
title={_(msg`Total Signers that Signed Up`)}
|
||||
data={signerConversionMonthly}
|
||||
cummulative
|
||||
/>
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions';
|
||||
import {
|
||||
Table,
|
||||
@ -11,20 +14,32 @@ import {
|
||||
} from '@documenso/ui/primitives/table';
|
||||
|
||||
export default async function Subscriptions() {
|
||||
setupI18nSSR();
|
||||
|
||||
const subscriptions = await findSubscriptions();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">Manage subscriptions</h2>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Manage subscriptions</Trans>
|
||||
</h2>
|
||||
<div className="mt-8">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created At</TableHead>
|
||||
<TableHead>Ends On</TableHead>
|
||||
<TableHead>User ID</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Status</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Created At</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Ends On</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>User ID</Trans>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@ -4,6 +4,9 @@ import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -27,8 +30,10 @@ export type DeleteUserDialogProps = {
|
||||
};
|
||||
|
||||
export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
@ -43,8 +48,8 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Account deleted',
|
||||
description: 'The account has been deleted successfully.',
|
||||
title: _(msg`Account deleted`),
|
||||
description: _(msg`The account has been deleted successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@ -52,17 +57,19 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
|
||||
} catch (err) {
|
||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||
toast({
|
||||
title: 'An error occurred',
|
||||
title: _(msg`An error occurred`),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
title: _(msg`An unknown error occurred`),
|
||||
variant: 'destructive',
|
||||
description:
|
||||
err.message ??
|
||||
'We encountered an unknown error while attempting to delete your account. Please try again later.',
|
||||
_(
|
||||
msg`We encountered an unknown error while attempting to delete your account. Please try again later.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -77,31 +84,39 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
|
||||
<div>
|
||||
<AlertTitle>Delete Account</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
Delete the users account and all its contents. This action is irreversible and will
|
||||
cancel their subscription, so proceed with caution.
|
||||
<Trans>
|
||||
Delete the users account and all its contents. This action is irreversible and will
|
||||
cancel their subscription, so proceed with caution.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">Delete Account</Button>
|
||||
<Button variant="destructive">
|
||||
<Trans>Delete Account</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>Delete Account</DialogTitle>
|
||||
<DialogTitle>
|
||||
<Trans>Delete Account</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
This action is not reversible. Please be certain.
|
||||
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<DialogDescription>
|
||||
To confirm, please enter the accounts email address <br />({user.email}).
|
||||
<Trans>
|
||||
To confirm, please enter the accounts email address <br />({user.email}).
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Input
|
||||
@ -119,7 +134,7 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
|
||||
variant="destructive"
|
||||
disabled={email !== user.email}
|
||||
>
|
||||
{isDeletingUser ? 'Deleting account...' : 'Delete Account'}
|
||||
<Trans>Delete account</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { Role } from '@documenso/prisma/client';
|
||||
@ -59,7 +60,9 @@ const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={selectedValues.join(', ')} />
|
||||
<CommandEmpty>No value found.</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
<Trans>No value found.</Trans>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allRoles.map((value: string, i: number) => (
|
||||
<CommandItem key={i} onSelect={() => handleSelect(value)}>
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
@ -28,7 +30,9 @@ const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
|
||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||
|
||||
export default function UserPage({ params }: { params: { id: number } }) {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { data: user } = trpc.profile.getUser.useQuery(
|
||||
@ -65,14 +69,14 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
router.refresh();
|
||||
|
||||
toast({
|
||||
title: 'Profile updated',
|
||||
description: 'Your profile has been updated.',
|
||||
title: _(msg`Profile updated`),
|
||||
description: _(msg`Your profile has been updated.`),
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while updating your profile.',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while updating your profile.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -80,7 +84,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">Manage {user?.name}'s profile</h2>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Manage {user?.name}'s profile</Trans>
|
||||
</h2>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset className="mt-6 flex w-full flex-col gap-y-4">
|
||||
@ -89,7 +95,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Name</FormLabel>
|
||||
<FormLabel className="text-muted-foreground">
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
@ -102,7 +110,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Email</FormLabel>
|
||||
<FormLabel className="text-muted-foreground">
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
@ -117,7 +127,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
render={({ field: { onChange } }) => (
|
||||
<FormItem>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
||||
<FormLabel className="text-muted-foreground">
|
||||
<Trans>Roles</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<MultiSelectRoleCombobox
|
||||
listValues={roles}
|
||||
@ -132,7 +144,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
Update user
|
||||
<Trans>Update user</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||