Merge branch 'main' into chore/profiles-announce

This commit is contained in:
Timur Ercan
2024-07-01 14:21:52 +02:00
committed by GitHub
141 changed files with 10681 additions and 1634 deletions

View File

@ -103,6 +103,12 @@ NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID=
# [[BACKGROUND JOBS]]
NEXT_PRIVATE_JOBS_PROVIDER="local"
NEXT_PRIVATE_TRIGGER_API_KEY=
NEXT_PRIVATE_TRIGGER_API_URL=
NEXT_PRIVATE_INNGEST_EVENT_KEY=
# [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags.
NEXT_PUBLIC_POSTHOG_KEY=""

View File

@ -4,6 +4,7 @@ module.exports = {
extends: ['@documenso/eslint-config'],
rules: {
'@next/next/no-img-element': 'off',
'no-unreachable': 'error',
},
settings: {
next: {

12
.vscode/settings.json vendored
View File

@ -5,11 +5,19 @@
"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,
"files.eol": "\n",
"editor.tabSize": 2,
"editor.insertSpaces": true
"editor.insertSpaces": true,
"[prisma]": {
"editor.defaultFormatter": "Prisma.prisma"
},
}

View File

@ -73,10 +73,22 @@ Contact us if you are interested in our Enterprise plan for large organizations
<a href="https://cal.com/timurercan/enterprise-customers?utm_source=banner&utm_campaign=oss"><img alt="Book us with Cal.com" src="https://cal.com/book-with-cal-dark.svg" /></a>
## Tech Stack
<p align="left">
<a href="https://www.typescriptlang.org"><img src="https://shields.io/badge/TypeScript-3178C6?logo=TypeScript&logoColor=FFF&style=flat-square" alt="TypeScript"></a>
<a href="https://nextjs.org/"><img src="https://img.shields.io/badge/next.js-000000?style=flat-square&logo=nextdotjs&logoColor=white" alt="NextJS"></a>
<a href="https://prisma.io"><img width="122" height="20" src="http://made-with.prisma.io/indigo.svg" alt="Made with Prisma" /></a>
<a href="https://tailwindcss.com/"><img src="https://img.shields.io/badge/tailwindcss-0F172A?&logo=tailwindcss" alt="Tailwind CSS"></a>
<a href=""><img src="" alt=""></a>
<a href=""><img src="" alt=""></a>
<a href=""><img src="" alt=""></a>
<a href=""><img src="" alt=""></a>
<a href=""><img src="" alt=""></a>
</p>
- [Typescript](https://www.typescriptlang.org/) - Language
- [Next.js](https://nextjs.org/) - Framework
- [Prisma](https://www.prisma.io/) - ORM
- [Prisma](https://www.prisma.io/) - ORM
- [Tailwind](https://tailwindcss.com/) - CSS
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
- [NextAuth.js](https://next-auth.js.org/) - Authentication

View File

@ -4,7 +4,7 @@ description: Today, we are launching direct links to templates, a new and async
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-06-06
date: 2024-06-17
tags:
- Announcement
- Direct Links
@ -16,7 +16,7 @@ tags:
src="/blog/direct-links.png"
width="1400"
height="884"
alt="Documenso announcement blog banner"
alt="Direct Links in Templats List View"
/>
<figcaption className="text-center">Direct Template Links - Async signing, anytime.</figcaption>
@ -25,6 +25,7 @@ tags:
> TLDR; We are launching direct links to templates. With direct links, a document is created from a template every time anyone signs the link. Links can be public.
## Sync or Async?
> Quick refresher on Sync vs. Async: Sync means everyone has to wait for me until they can continue their work. Async means everyone can and does their work at the time that fits best.
Digital signing has become almost as normalized as email when doing business. While not 100% of companies are onboarded on digital signatures yet, hardly anyone is surprised when receiving a link to sign something digitally. As we got used to the user experience of sending emails, we also got used to the experience of sending document signature requests, with all the downsides:
@ -34,6 +35,7 @@ Digital signing has become almost as normalized as email when doing business. Wh
- I need to monitor the requests I started for completion: "I sent you a link yesterday; please check it out."
## Introducing Direct Links
Today, we are introducing a new paradigm to signing: Async Direct Signing Links. Direct links are attached to a template and can be used anytime by anyone using the link. You set up the signature experience and flow once using all existing template mechanisms and you are done. You can provide anyone with the link so they can sign whenever they need to. You can even post the link publicly if you want to maximize its reach, i.e. for sales contracts.
<video
@ -45,18 +47,17 @@ Today, we are introducing a new paradigm to signing: Async Direct Signing Links.
muted
></video>
## Embrace Async
So, how does this help anyone? You may still need to send a signature request to people, but in the cases you don't, you are not forced to anymore. Need an NDA? Check out our standing NDA link. A customer needs an updated Form W-9? Just use the company W-9 Link; it always has the most up-to-date form. You can even go as far as publicly posting a link to a software development or design contract any potential customer can sign anytime. Can they talk to you first? Sure, but if they don't need to or already have to, they go straight to the link. The process of actively sending has gotten us used to using a sync paradigm (I send, you receive and sign, and I get the result), whereas an async one (you sign whenever it suits you, and I become active only then, if at all) is way better suited. Adding more approval and signature steps makes sure you still control the outcome, but the process becomes a lot more efficient. For example, you can grab your own copy of the early adopter's pledge here if you missed it: [documen.so/pledge](https://documen.so/pledge).
> Take a minute to think about every signing request you send and whether they really require you to be part of the transaction. Could they be outsourced to the recipient, only to be reviewed once their part is done?
> Take a minute to think about every signing request you send and whether they really require you to be part of the transaction. Could they be outsourced to the recipient and only reviewed once their part is done?
## Coming Soon: Profiles
The best place to put your public links will be your **Documenso profile**, which is also close to launching. We want to get a feel for how links are used and move on to profiles shortly after. Want to try out direct links? Grab a free account here to get started: [documen.so/free](https://documen.so/free).
As always, we want to hear from you on [Twitter / X](https://twitter.com/eltimuro) (DMs are open) or [Discord](https://documen.so/discord) if you have any questions, ideas or comments.
As always, we want to hear from you 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

View File

@ -0,0 +1,103 @@
---
title: How Documenso Enhances Contract Management for Freelancers, Helping Them Close More Clients Efficiently
description: Making it easy for the customer to sign the contract after they say yes is critical. Let take a look how Documenso can help.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-06-20
tags:
- Freelancer
- Proposal
- Productivity
---
## Yes to Yes
> [Check out Part 1](https://documen.so/freelance-proposal) to learn about signing freelance proposals with Documenso and getting your first yes
A basic rule of sales is going from "yes to yes”. Outlining the main points of working together in a proposal is a good way to get to your first yes since it reduces details and focuses on the main points of the work at hand. After being on the same page about the work and getting the first yes, it's time to draw up a formal contract. While agreeing to the proposal has some weight as well, the legal contract formalizes the commitments of both sides in an enforceable way. Having clear legal terms on payments, unexpected cases, and even dissolving the partnership helps both parties to feel assured about what to expect.
### **Digital Signatures for the Win**
Digitally signing documents accelerates contract closure, enhancing both speed and security. Parties can review and sign documents within minutes, eliminating the days required for manual signatures or even weeks with traditional mail. Beyond these efficiency gains, digital signatures boost trust by making the process secure and auditable. Once signed, digital documents are immutable, and every step is logged.
Documenso simplifies this process, allowing you to send contracts effortlessly. As an open-source solution, our product's integrity and security are verifiable by anyone, which is why thousands of users rely on Documenso for their signing needs. Discover more at [https://documen.so/open](https://documen.so/open).
## Preparing the Contract
As a freelancer, obtaining a contract template ensures you have a standardized and professional agreement ready for new clients, helping to protect your interests and clarify project terms. While there are many good templates out there, be sure to verify that they fit your case since contracts are often very specific to a certain case. Always consider having your contract checked by a legal professional if it's a high-value transaction.
Here is a quick checklist of what your contract should include:
### Checklist
- Names and Addresses of you and your client
- Scope of Work to be performed, deadlines and deliverables
- Payment Terms, Payment Schedules, and Pricing
- A clear timeline
- Provisions for unexpected extra work
- Intellectual Property Rights Provisions
- Confidentiality and Non-Disclosure Agreements, if needed
- Termination Clauses: Condition and terms when the contract can be terminated, including notice period and compensation
- Indemnity and Liability
- Dispute Resolution
- Provisions ensuring changes can only be made in writing
- Completeness Agreement: Both parties state this is the full extent of the agreement
- Severability Clause ensuring minor errors will not endanger the whole contract
- The signees with name, role, and date
## Getting the Signature
Once you have your contract ready, you can upload it and add recipients and signature fields. To add a more personal touch, consider adding a personal message to the signature request.
<figure>
<MdxNextImage
src="/blog/l1.png"
width="1260"
height="630"
alt="Manually Copy Signature Link by Hovering of the Recipient"
/>
<figcaption className="text-center">
Copy recipient links to send them for a personal touch manually.
</figcaption>
</figure>
You can also copy the link for each recipient after sending it and send it via another channel e.g. WhatsApp with a personal message. To further customize the experience, you can define a redirect when your customer signs the contract to redirect them to a Cal.com Link to get started, a Thank You Page, or a Form.
<figure>
<MdxNextImage
src="/blog/l2.png"
width="1260"
height="630"
alt="Redirect Link in Advanced Settings"
/>
<figcaption className="text-center">
Redirect after Signing for a more personal experience.
</figcaption>
</figure>
The more you add to the workflow, the more important it is to keep up to date with the process. Using Zapier, you can add a variety of notifications, from email to Discord messages, to keep a good overview and respond quickly. It's not just about getting the signatures; it's about creating the workflow that provides the best experience for you and your customers.
<figure>
<MdxNextImage
src="/blog/l3.png"
width="1260"
height="630"
alt="Zapier Documenso Discord Integration"
/>
<figcaption className="text-center">
Trigger any kind of notification with[Zapier](https://documen.so/zapier)
</figcaption>
</figure>
### Conclusion
Sending a contract to clients using Documenso makes the process fast and easy. Seeing if your contract was signed or even read helps you understand where you are in the process. You can use the [Documenso Free Plan](https://app.documenso.com/signup?utm_source=blog-freelancer-contract) to send 5 contracts per month. Digital signing in 2024 is the best practice for professionals seeking the most efficient way to get business done.
Let us know what you think and what we can improve. Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
Best from Hamburg\
Timur

View File

@ -0,0 +1,76 @@
---
title: How Documenso Helps Freelancers Close More Clients Efficiently
description: Reducing friction when sending proposals is critical to closing new clients. By using Documenso, freelancers can save time, enhance client interactions, and ultimately close more deals efficiently.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-06-14
tags:
- Freelancer
- Proposal
- Productivity
---
Getting new clients, or maybe even your first client, to sign with you is at the very core of freelance work. Whether you develop software, create designs, or market products, it all starts with a signature from you and your future customer. Closing a customer usually means agreeing on a proposal first and then signing a formal agreement afterward. Signing proposals and contracts is fast, easy, and painless with Documenso, so let's take a look.
## Understanding Proposal and Contracts
### 1. Initial Proposal
> Agreeing on what needs to be done and terms for payment
A proposal will include the scope of the work (what does the customer want done?), desired deliverables (documents, code, features, videos, etc.), timelines, payment terms (one-time, monthly, per hour), and prices (e.g. $60/ hour, $5k one-time). A proposal is important for both sides to be clear about the goal and the terms that apply. Customers usually decide based on the proposal if your offer is what they want.
### 2. Formal Contract
> After the client signs the proposal, a contract can be signed, formalizing the agreement and adding detailed legal terms.
Once the terms are agreed upon, a more formal document should specify the terms of working together, especially legal details such as confidentiality, indemnification, governing law, etc. The contract provides clarity on legal details for both sides and formalizes their claims.
Sending one or even multiple documents to a potential client and having them send them back signed (in the worst case, they send it via actual mail) is time-consuming for both sides. It also introduces friction at a time when making it as easy as possible for your potential client to say yes should be your number one goal.
### **Digital Signatures for the Win**
Signing documents digitally makes closing proposals and contracts faster and more secure. Each party can review and sign the documents in minutes instead of days (inserting the signatures manually via PDF editor) or even weeks (using conventional mail). Apart from the efficiency gains, signing digitally also increases trust by making the process more secure and auditable. Digitally signed documents cant be changed after the fact, and every step of the process is logged.
Documenso lets you reap these benefits by sending proposals and contracts with minimal effort. Being open source, the whole world can verify our product and how we deliver on these promises, which is why thousands of users already trust Documenso for their signing needs: [https://documen.so/open](https://documen.so/open).
## Preparing the Proposal
If you already have a proposal template, create a new version for your client and export it to PDF. If your tool doesnt support that, your system's “PDF printer” lets you create a PDF from almost any tool by using the print function. If you do not have a template yet, you can find a lot of content and guides on the matter through a quick Google search. Here is a quick checklist of what your proposal should cover:
- A clear and concise title
- Your contact information
- Date of the proposal/ validity period
- Experience, qualifications, and prior relevant projects
- A summary of the project
- A detailed description of the project
- Goals, outcomes, and deliverables
- Tasks and activities to achieve the goals and outcomes
- A timeline with milestones and deadlines
- Pricing terms and payment schedule
- Summary of major terms for the coming contract
## Sending the Proposal
If you dont have a Documenso Account yet, you can [create one for free](https://documen.so/signup?utm_source=blog-freelancer-proposal). Once you sign up, you can upload your proposal PDF by simply dragging it into the upload area. Add your potential client as a recipient, add a signature field, and you are done! You can track the status of your proposal simply by clicking the Document in the overview. Documenso will also notify you once the proposal is signed.
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/050a1501-b562-4b1e-97b5-a46fc0da8246"
autoPlay
loop
muted
></video>
### Conclusion
Sending a proposal to potential clients using Documenso makes getting to the first “yes” fast and easy. Seeing if your proposal was signed or even read helps you to get a feel for where you are in the process. You can use the [Documenso Free Plan](https://app.documenso.com/signup?utm_source=blog-freelancer-proposal) to send 5 proposals per month. Digital Signing in 2024 is the best practice for all professionals looking for the most efficient way to get business done.
> [Check out Part 2](https://documen.so/freelance-contract) to learn about signing freelance contracts with Documenso.
Let us know what you think and what we can improve. Which field types are you missing? Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
Best from Hamburg\
Timur

View File

@ -0,0 +1,88 @@
---
title: How to Sign an NDA online (fast)
description: Signing an NDA with Documenso direct links is amazingly fast. Lets look at how to make it even faster.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-06-27
tags:
- NDA
- Direct Links
---
<figure>
<MdxNextImage
src="/blog/nda.jpg"
width="1400"
height="884"
alt="Direct Links in Templates List View"
/>
<figcaption className="text-center">A generic document saying "NDA" to underline this article is about NDAs.</figcaption>
</figure>
> TLDR; Documenso makes sending NDAs faster, faster with templates, and even still faster using direct links.
## What is an NDA?
An NDA, or non-disclosure agreement, is a legal contract that establishes a confidential relationship. The parties involved agree not to disclose information covered by the agreement. NDAs are often used to protect sensitive information or trade secrets and to ensure that such information isn't made public by the recipient without permission. They are commonly used in business settings, such as during negotiations or when new employees are hired who will have access to proprietary information.
## Do I need an NDA?
> Disclaimer: This is not legal advice, and the most important legal questions are often ultra-case-specific and should be discussed with a legal professional
There is a solid amount of debate around the question of whether an NDA actually protects anyone and is worth the friction. Investors scoff at the idea of a startup requiring them to sign an NDA before disclosing their "billion dollar idea" as they see hundreds of them and are aware that without proper execution, there is nothing to protect.
In another classical example, a big company and a small company e.g. a startup, sign an NDA before going into detail for partnership talks. While this seems prudent, given the resource asymmetry, the startup probably won't be able to litigate against breach of contract successfully. If Microsoft (not saying they do) breaks your NDA, you will hardly sue them into a settlement.
That being said, as with most contracts, NDAs can be useful if both parties keep the spirit of the agreement. In this case, detailing in writing what can and cannot be disclosed is good for managing expectations and building trust. NDAs are also common practice in merger and acquisition projects and are often part of hiring critical roles within a company.
### Level 1: Basic Signing
If you need to sign an NDA, signing it with Documenso is incredibly fast already. Let's take a look at how to make it even faster. Simply uploading and sending is the most straightforward way to get this done. It works like this:
- Upload the NDA PDF template
- Add the recipients
- place the signature fields
- Hit send
- Sign the NDA while or after waiting for your counterpart to sign
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/raw/main/blog/nda1.webm"
autoPlay
loop
muted
controls
></video>
### Level 2: Using Templates
If you have to sign the same NDA multiple times with different people, you can create a template to save time. Creating a template is just as easy as creating a document, just skipping the sending step. After creating the template for your NDA, you can create a signable document with just 1 click. Simply fill in the recipient email, and you are done.
> Pro Tip: Check "Send Document" to immediately send it after filling out the recipient if you are familiar with the template.
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/raw/main/blog/nda2.webm"
autoPlay
loop
muted
controls
></video>
<figcaption className="text-center">
You can send out templates without even going through the full flow.
</figcaption>
### Level 3: Using a Direct Link
Using a pre-defined template is pretty fast, but can we make it even faster? Yes, we can! By adding a direct link to your NDA template and publishing it (internally or even externally). A [Direct Link](http://documenso.com/blog/announcing-direct-links) lets people sign your NDA without you lifting a finger. Everyone with access to the link can sign it at any time, making discussions of who sends what when a thing of the past. You can use Direct links with a pre-signed template for maximum convenience or with a second signer/ approver from your side to keep control over the process.
You can try it here and [sign a demo NDA](https://documen.so/demo-nda) with me.
> Pro Tip: Use [Zapier](https://documen.so/zapier) to get notified of that platform of your choice as soon as someone signs your link.
## Conclusion
Signing NDAs is not always effective, but it can be necessary, so be sure to use a tool to make it easy and fast. Documenso is a great DocuSign alternative that helps you get it done. If you need to get an NDA out today, you can use the [Documenso Free plan](https://documen.so/free), which gives you 5 signatures per month and 3 Direct Link templates.
As always, we want to hear from you 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

View File

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

View File

@ -0,0 +1,55 @@
---
title: Changelog - Documenso
---
# Changelog
Check out what's new in the latest major version and read what we think about it. You can find our releases on GitHub for more technical details [here](https://github.com/documenso/documenso/releases). You can find our [release candidates here](https://github.com/documenso/documenso/tags).
---
## v1.5.5 (latest)
### <small>Released 6th May 2024</small>
> This release contains [20 fixes](https://github.com/documenso/documenso/releases/tag/v1.5.5)
### ✅ Show Completed Fields
Fields completed by other recipients are now visible to everyone to communicate the state of the document better and allow users an informed decision on what they are signing.
### ⬇️ Download Completed Documents via API
Completed documents can now be downloaded via the API using this new endpoint:
**GET /API/V1//DOCUMENTS/\{ID\}/DOWNLOAD**
Check out the full Open API docs here: [https://documen.so/openapi](https://documen.so/openapi)
### Adding Yourself as a Signer
Adding yourself as a signer is now just one click away.
---
## v1.5.4
### <small>Released 11th April 2024</small>
> This release contains [21 fixes](https://github.com/documenso/documenso/releases/tag/v1.5.4)
#### 🔑 Passkeys
To improve security and usability for high-security setups, we added passkeys with this release. Passkeys can now be used to log in or re-authenticate each signature for high-compliance cases.
#### 📄 Signing Certificate & Audit Log
On the security/ compliance side, we also added Signing Certificates and Audit Logs. Every signed document now has a certificate attached, showing technical details of the signature to improve transparency and security. Further, every action on a document from creation to completion is now logged in the audit log to guarantee the integrity of the process.
#### 🔏🦀 @documenso/pdf-sign
We are pretty hyped about this one: Since version 0.9, we relied on https://github.com/vbuch/node-signpdf to add the digital signatures to our documents. Since signing is at the heart of Documenso, we created our own rust-based library for signing. As of 1.5.4, Documenso's signing runs on @documenso/pdf-sign. The library offers a better architecture to enable signing with private keys that are not stored locally (e.g. via HSM). We are in the process of cleaning up the library to open source it like the rest of Documenso 🌱 The library will also help us to offer Long Term Validation (LTV) for signatures soon. While we are currently limited to signing with PKCS7-B, eventually, we plan to support all common signing standards like PAdES, CAdES, and XAdES.
---
´

View File

@ -21,6 +21,9 @@
"@hookform/resolvers": "^3.1.0",
"@openstatus/react": "^0.0.3",
"contentlayer": "^0.3.4",
"embla-carousel": "^8.1.3",
"embla-carousel-autoplay": "^8.1.3",
"embla-carousel-react": "^8.1.3",
"framer-motion": "^10.12.8",
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
@ -38,7 +41,7 @@
"react-hook-form": "^7.43.9",
"react-icons": "^4.11.0",
"recharts": "^2.7.2",
"sharp": "^0.33.1",
"sharp": "0.32.6",
"typescript": "5.2.2",
"zod": "^3.22.4"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 831 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

Binary file not shown.

View File

@ -47,14 +47,6 @@ export const TEAM_MEMBERS = [
engagement: 'Full-Time',
joinDate: 'October 9th, 2023',
},
{
name: 'Adithya Krishna',
role: 'Software Engineer - II',
salary: '-',
location: 'India',
engagement: 'Full-Time',
joinDate: 'December 1st, 2023',
},
];
export const FUNDING_RAISED = [

View File

@ -247,8 +247,8 @@ export default async function OpenPage() {
<BarMetric<EarlyAdoptersType>
data={EARLY_ADOPTERS_DATA}
metricKey="earlyAdopters"
title="Early Adopters"
label="Early Adopters"
title="Total Customers"
label="Total Customers"
className="col-span-12 lg:col-span-6"
extraInfo={<OpenPageTooltip />}
/>

View File

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

View File

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

View File

@ -34,17 +34,18 @@ export const Callout = ({ starCount }: CalloutProps) => {
return (
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4">
<Button
type="button"
variant="outline"
className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
>
Claim Early Adopter Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
$30/mo
</span>
</Button>
<Link href="https://app.documenso.com/signup?utm_source=marketing-callout">
<Button
type="button"
variant="outline"
className="rounded-full bg-transparent backdrop-blur-sm"
>
Try our Free Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
No Credit Card required
</span>
</Button>
</Link>
<Link
href="https://github.com/documenso/documenso"

View File

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

View File

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

View File

@ -35,6 +35,7 @@ const FOOTER_LINKS = [
{ href: '/oss-friends', text: 'OSS Friends' },
{ href: '/careers', text: 'Careers' },
{ href: '/privacy', text: 'Privacy' },
{ href: '/changelog', text: 'Changelog' },
];
export const Footer = ({ className, ...props }: FooterProps) => {

View File

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

View File

@ -58,7 +58,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
>
Yearly
<div className="bg-muted text-foreground block rounded-full px-2 py-0.5 text-xs">
Save $60
Save $60 or $120
</div>
{period === 'YEARLY' && (
<motion.div
@ -75,7 +75,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
data-plan="free"
className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg"
>
<p className="text-foreground text-4xl font-medium">Free Plan</p>
<p className="text-foreground text-4xl font-medium">Free</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">
@ -102,10 +102,10 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</div>
<div
data-plan="early-adopter"
className="border-primary bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border-2 px-8 py-12 shadow-[0px_0px_0px_4px_#E3E3E380]"
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">Early Adopters</p>
<p className="text-foreground text-4xl font-medium">Individual</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,12 +114,12 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</div>
<p className="text-foreground mt-4 max-w-[30ch] text-center">
For fast-growing companies that aim to scale across multiple teams.
Everything you need for a great signing experience.
</p>
<Button className="mt-6 rounded-full text-base" asChild>
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-early-adopter`}
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-individual-plan`}
target="_blank"
>
Signup Now
@ -127,51 +127,48 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</Button>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="text-foreground py-4">
<a
href="https://documen.so/early-adopters-pricing-page"
target="_blank"
rel="noreferrer"
>
Limited Time Offer: <span className="text-documenso-700">Read More</span>
</a>
</p>
<p className="text-foreground py-4">Unlimited Teams</p>
<p className="text-foreground py-4">Unlimited Users</p>
<p className="text-foreground py-4">Unlimited Documents per month</p>
<p className="text-foreground py-4">Includes all upcoming features</p>
<p className="text-foreground py-4">Email, Discord and Slack assistance</p>
<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>
</div>
<div className="flex-1" />
</div>
<div
data-plan="enterprise"
className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg"
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">Enterprise</p>
<p className="text-primary mt-2.5 text-xl font-medium">Pricing on request</p>
<p className="text-foreground text-4xl font-medium">Teams</p>
<div className="text-primary mt-2.5 text-xl font-medium">
<AnimatePresence mode="wait">
{period === 'MONTHLY' && <motion.div layoutId="pricingTeams">$50</motion.div>}
{period === 'YEARLY' && <motion.div layoutId="pricingTeams">$480</motion.div>}
</AnimatePresence>
</div>
<p className="text-foreground mt-4 max-w-[30ch] text-center">
For large organizations that need extra flexibility and control.
For companies looking to scale across multiple teams.
</p>
<Link
href="https://dub.sh/enterprise"
target="_blank"
className="mt-6"
onClick={() => event('enterprise-contact')}
>
<Button className="rounded-full text-base">Contact Us</Button>
</Link>
<Button className="mt-6 rounded-full text-base" asChild>
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-teams-plan`}
target="_blank"
>
Signup Now
</Link>
</Button>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="text-foreground py-4 font-medium">Everything in Early Adopters, plus:</p>
<p className="text-foreground py-4">Custom Subdomain</p>
<p className="text-foreground py-4">Compliance Check</p>
<p className="text-foreground py-4">Guaranteed Uptime</p>
<p className="text-foreground py-4">Reporting & Analysis</p>
<p className="text-foreground py-4">24/7 Support</p>
<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.'}
</p>
</div>
</div>
</div>

View File

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

View File

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

View File

@ -48,8 +48,9 @@
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
"recharts": "^2.7.2",
"remeda": "^1.27.1",
"sharp": "^0.33.1",
"sharp": "0.32.6",
"ts-pattern": "^5.0.5",
"ua-parser-js": "^1.0.37",
"uqr": "^0.1.2",

View File

@ -0,0 +1,33 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1080_12656)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.56772 0.890928C9.5882 -0.296974 11.4118 -0.296978 12.4323 0.890927L13.2272 1.81624C13.3589 1.96964 13.5596 2.0435 13.758 2.01166L14.955 1.81961C16.4917 1.57307 17.8887 2.75864 17.9154 4.33206L17.9363 5.55768C17.9398 5.76086 18.0465 5.94788 18.2188 6.0525L19.2578 6.68358C20.5916 7.49375 20.9083 9.31015 19.9288 10.5329L19.1659 11.4853C19.0394 11.6432 19.0023 11.8559 19.0678 12.048L19.4627 13.2069C19.9696 14.6947 19.0578 16.292 17.5304 16.5919L16.3406 16.8255C16.1434 16.8643 15.9798 17.0031 15.9079 17.1928L15.4738 18.3373C14.9166 19.8066 13.203 20.4374 11.8423 19.6741L10.7825 19.0796C10.6068 18.981 10.3932 18.981 10.2175 19.0796L9.15768 19.6741C7.79704 20.4374 6.08341 19.8066 5.52618 18.3373L5.09212 17.1928C5.02017 17.0031 4.8566 16.8643 4.65937 16.8255L3.46962 16.5919C1.94224 16.292 1.03044 14.6947 1.53734 13.2069L1.93219 12.048C1.99765 11.8559 1.96057 11.6432 1.8341 11.4853L1.07116 10.5329C0.0917119 9.31015 0.408373 7.49375 1.74223 6.68358L2.78123 6.0525C2.95348 5.94788 3.06024 5.76086 3.0637 5.55768L3.08456 4.33206C3.11133 2.75864 4.50829 1.57307 6.04498 1.81961L7.24197 2.01166C7.4404 2.0435 7.64105 1.96964 7.77282 1.81624L8.56772 0.890928Z" fill="url(#paint0_linear_1080_12656)"/>
<g filter="url(#filter0_di_1080_12656)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.3714 14.5609C13.5195 14.6358 13.6925 14.5149 13.6642 14.3563L13.1163 11.2805L15.4388 9.10299C15.5586 8.9907 15.4925 8.79506 15.327 8.77192L12.1176 8.32508L10.681 5.52519C10.6069 5.38093 10.3931 5.38093 10.319 5.52519L8.88116 8.32354L5.673 8.77192C5.50748 8.79506 5.44139 8.9907 5.56116 9.10299L7.8843 11.2803L7.33579 14.3563C7.30752 14.5149 7.48055 14.6358 7.62859 14.5609L10.5014 13.1083L13.3714 14.5609Z" fill="#FFFCEB"/>
</g>
</g>
<defs>
<filter id="filter0_di_1080_12656" x="5.33521" y="5.41699" width="10.6591" height="9.90853" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="0.164785" dy="0.411963"/>
<feGaussianBlur stdDeviation="0.164785"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.414307 0 0 0 0 0.24341 0 0 0 0 0.0856598 0 0 0 0.1 0"/>
<feBlend mode="plus-darker" in2="BackgroundImageFix" result="effect1_dropShadow_1080_12656"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1080_12656" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="0.164785" dy="0.164785"/>
<feGaussianBlur stdDeviation="0.0823927"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/>
<feBlend mode="screen" in2="shape" result="effect2_innerShadow_1080_12656"/>
</filter>
<linearGradient id="paint0_linear_1080_12656" x1="12.5596" y1="-9.0568e-08" x2="6.25112" y2="19.9592" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE76A"/>
<stop offset="1" stop-color="#E8C445"/>
</linearGradient>
<clipPath id="clip0_1080_12656">
<rect width="20" height="20" fill="white" transform="translate(0.5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,9 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.54474 0.890944C9.57689 -0.296979 11.4213 -0.296983 12.4535 0.890943L13.2575 1.81628C13.3908 1.96967 13.5937 2.04354 13.7944 2.0117L15.0051 1.81965C16.5593 1.57309 17.9723 2.75869 17.9994 4.33214L18.0205 5.55778C18.024 5.76096 18.1319 5.94799 18.3061 6.05261L19.357 6.6837C20.7061 7.49389 21.0264 9.31032 20.0358 10.5331L19.2641 11.4855C19.1362 11.6434 19.0987 11.8561 19.1649 12.0482L19.5643 13.2072C20.077 14.695 19.1547 16.2923 17.6099 16.5922L16.4065 16.8258C16.207 16.8646 16.0416 17.0034 15.9688 17.1931L15.5298 18.3376C14.9662 19.8069 13.233 20.4378 11.8568 19.6745L10.7848 19.08C10.6071 18.9814 10.3911 18.9814 10.2134 19.08L9.14145 19.6745C7.76525 20.4378 6.03203 19.8069 5.46842 18.3376L5.0294 17.1931C4.95662 17.0034 4.79119 16.8646 4.5917 16.8258L3.38834 16.5922C1.8435 16.2923 0.921268 14.695 1.43397 13.2072L1.83334 12.0482C1.89954 11.8561 1.86204 11.6434 1.73412 11.4855L0.962455 10.5331C-0.0281913 9.31032 0.292091 7.49389 1.6412 6.6837L2.69209 6.05261C2.8663 5.94799 2.97428 5.76096 2.97778 5.55778L2.99888 4.33214C3.02596 2.75869 4.4389 1.57309 5.99315 1.81965L7.20383 2.0117C7.40454 2.04354 7.60747 1.96967 7.74076 1.81628L8.54474 0.890944ZM13.7062 9.20711C14.0968 8.81658 14.0968 8.18342 13.7062 7.79289C13.3157 7.40237 12.6825 7.40237 12.292 7.79289L9.49912 10.5858L8.70622 9.79289C8.3157 9.40237 7.68253 9.40237 7.29201 9.79289C6.90148 10.1834 6.90148 10.8166 7.29201 11.2071L8.43846 12.3536C9.02425 12.9393 9.97399 12.9393 10.5598 12.3536L13.7062 9.20711Z" fill="url(#paint0_linear_1080_12647)"/>
<defs>
<linearGradient id="paint0_linear_1080_12647" x1="12.5823" y1="-9.05696e-08" x2="6.33214" y2="20.0004" gradientUnits="userSpaceOnUse">
<stop stop-color="#96D766"/>
<stop offset="1" stop-color="#5AAE30"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -14,18 +14,34 @@ import {
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
import {
getUserWithAtLeastOneDocumentPerMonth,
getUserWithAtLeastOneDocumentSignedPerMonth,
getUserWithSignedDocumentMonthlyGrowth,
getUsersCount,
getUsersWithSubscriptionsCount,
} from '@documenso/lib/server-only/admin/get-users-stats';
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
import { UserWithDocumentChart } from './user-with-document';
export default async function AdminStatsPage() {
const [usersCount, usersWithSubscriptionsCount, docStats, recipientStats] = await Promise.all([
const [
usersCount,
usersWithSubscriptionsCount,
docStats,
recipientStats,
userWithAtLeastOneDocumentPerMonth,
userWithAtLeastOneDocumentSignedPerMonth,
MONTHLY_USERS_SIGNED,
] = await Promise.all([
getUsersCount(),
getUsersWithSubscriptionsCount(),
getDocumentStats(),
getRecipientsStats(),
getUserWithAtLeastOneDocumentPerMonth(),
getUserWithAtLeastOneDocumentSignedPerMonth(),
getUserWithSignedDocumentMonthlyGrowth(),
]);
return (
@ -43,12 +59,11 @@ export default async function AdminStatsPage() {
<CardMetric icon={UserPlus2} title="App Version" value={`v${process.env.APP_VERSION}`} />
</div>
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-1 lg:grid-cols-2">
<div className="mt-16 gap-8">
<div>
<h3 className="text-3xl font-semibold">Document metrics</h3>
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
<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} />
@ -58,7 +73,7 @@ export default async function AdminStatsPage() {
<div>
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
<CardMetric
icon={UserSquare2}
title="Total Recipients"
@ -70,6 +85,23 @@ export default async function AdminStatsPage() {
</div>
</div>
</div>
<div className="mt-16">
<h3 className="text-3xl font-semibold">Charts</h3>
<div className="mt-5 grid grid-cols-2 gap-10">
<UserWithDocumentChart
data={MONTHLY_USERS_SIGNED}
title="MAU (created document)"
tooltip="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"
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
'use client';
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { TooltipProps } from 'recharts';
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
import type { GetUserWithDocumentMonthlyGrowth } from '@documenso/lib/server-only/admin/get-users-stats';
export type UserWithDocumentChartProps = {
className?: string;
title: string;
data: GetUserWithDocumentMonthlyGrowth;
completed?: boolean;
tooltip?: string;
};
const CustomTooltip = ({
active,
payload,
label,
tooltip,
}: TooltipProps<ValueType, NameType> & { tooltip?: string }) => {
if (active && payload && payload.length) {
return (
<div className="z-100 w-60 space-y-1 rounded-md border border-solid bg-white p-2 px-3">
<p className="">{label}</p>
<p className="text-documenso">
{`${tooltip} : `}
<span className="text-black">{payload[0].value}</span>
</p>
</div>
);
}
return null;
};
export const UserWithDocumentChart = ({
className,
data,
title,
completed = false,
tooltip,
}: UserWithDocumentChartProps) => {
const formattedData = (data: GetUserWithDocumentMonthlyGrowth, completed: boolean) => {
return [...data].reverse().map(({ month, count, signed_count }) => {
const formattedMonth = DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL');
if (completed) {
return {
month: formattedMonth,
count: Number(signed_count),
};
} else {
return {
month: formattedMonth,
count: Number(count),
};
}
});
};
return (
<div className={className}>
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2">
<div className="mb-6 flex h-12 px-4">
<h3 className="text-lg font-semibold">{title}</h3>
</div>
<ResponsiveContainer width="100%" height={400}>
<BarChart className="bg-white" data={formattedData(data, completed)}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip
content={<CustomTooltip tooltip={tooltip} />}
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={60}
label={tooltip}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@ -19,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const [{ users, totalPages }, individualPrices] = await Promise.all([
search(searchString, page, perPage),
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY).catch(() => []),
getPricesByPlan([STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY]).catch(() => []),
]);
const individualPriceIds = individualPrices.map((price) => price.id);

View File

@ -8,7 +8,7 @@ import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getCompletedFieldsForDocument } from '@documenso/lib/server-only/field/get-completed-fields-for-document';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
@ -86,14 +86,15 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
documentMeta.password = securePassword;
}
const [recipients, completedFields] = await Promise.all([
const [recipients, fields] = await Promise.all([
getRecipientsForDocument({
documentId,
teamId: team?.id,
userId: user.id,
}),
getCompletedFieldsForDocument({
getFieldsForDocument({
documentId,
userId: user.id,
}),
]);
@ -163,10 +164,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
</Card>
{document.status === DocumentStatus.PENDING && (
<DocumentReadOnlyFields
fields={completedFields}
documentMeta={document.documentMeta || undefined}
/>
<DocumentReadOnlyFields fields={fields} documentMeta={documentMeta || undefined} />
)}
<div className="col-span-12 lg:col-span-6 xl:col-span-5">

View File

@ -39,7 +39,7 @@ export default async function BillingSettingsPage() {
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.REGULAR }),
getPrimaryAccountPlanPrices(),
]);

View File

@ -1,46 +0,0 @@
'use client';
import { useState } from 'react';
import type { User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { ClaimPublicProfileDialogForm } from '~/components/forms/public-profile-claim-dialog';
export type ClaimProfileAlertDialogProps = {
className?: string;
user: User;
};
export const ClaimProfileAlertDialog = ({ className, user }: ClaimProfileAlertDialogProps) => {
const [open, setOpen] = useState(false);
return (
<>
<Alert
className={cn(
'flex flex-col items-center justify-between gap-4 p-6 md:flex-row',
className,
)}
variant="neutral"
>
<div>
<AlertTitle>{user.url ? 'Update your profile' : 'Claim your profile'}</AlertTitle>
<AlertDescription className="mr-2">
{user.url
? 'Profiles are coming soon! Update your profile username to reserve your corner of the signing revolution.'
: 'Profiles are coming soon! Claim your profile username now to reserve your corner of the signing revolution.'}
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Button onClick={() => setOpen(true)}>{user.url ? 'Update Now' : 'Claim Now'}</Button>
</div>
</Alert>
<ClaimPublicProfileDialogForm open={open} onOpenChange={setOpen} user={user} />
</>
);
};

View File

@ -3,9 +3,9 @@ import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { ProfileForm } from '~/components/forms/profile';
import { ClaimProfileAlertDialog } from './claim-profile-alert-dialog';
import { DeleteAccountDialog } from './delete-account-dialog';
export const metadata: Metadata = {
@ -19,10 +19,9 @@ export default async function ProfileSettingsPage() {
<div>
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
<AvatarImageForm className="mb-8 max-w-xl" user={user} />
<ProfileForm className="mb-8 max-w-xl" user={user} />
<ClaimProfileAlertDialog className="max-w-xl" user={user} />
<hr className="my-4 max-w-xl" />
<DeleteAccountDialog className="max-w-xl" user={user} />

View File

@ -0,0 +1,14 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
import { PublicProfilePageView } from './public-profile-page-view';
export default async function Page() {
const { user } = await getRequiredServerComponentSession();
const { profile } = await getUserPublicProfile({
userId: user.id,
});
return <PublicProfilePageView user={user} profile={profile} />;
}

View File

@ -0,0 +1,207 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import type {
Team,
TeamProfile,
TemplateDirectLink,
User,
UserProfile,
} from '@documenso/prisma/client';
import { TemplateType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Switch } from '@documenso/ui/primitives/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form';
import { PublicProfileForm } from '~/components/forms/public-profile-form';
import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog';
import { PublicTemplatesDataTable } from './public-templates-data-table';
export type PublicProfilePageViewOptions = {
user: User;
team?: Team;
profile: UserProfile | TeamProfile;
};
type DirectTemplate = FindTemplateRow & {
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
};
const userProfileText = {
settingsTitle: 'Public Profile',
settingsSubtitle: 'You can choose to enable or disable your profile for public view.',
templatesTitle: 'My templates',
templatesSubtitle:
'Show templates in your public profile for your audience to sign and get started quickly',
};
const teamProfileText = {
settingsTitle: 'Team Public Profile',
settingsSubtitle: 'You can choose to enable or disable your team profile for public view.',
templatesTitle: 'Team templates',
templatesSubtitle:
'Show templates in your team public profile for your audience to sign and get started quickly',
};
export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePageViewOptions) => {
const { toast } = useToast();
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const { data } = trpc.template.findTemplates.useQuery({
perPage: 100,
teamId: team?.id,
});
const { mutateAsync: updateUserProfile, isLoading: isUpdatingUserProfile } =
trpc.profile.updatePublicProfile.useMutation();
const { mutateAsync: updateTeamProfile, isLoading: isUpdatingTeamProfile } =
trpc.team.updateTeamPublicProfile.useMutation();
const isUpdating = isUpdatingUserProfile || isUpdatingTeamProfile;
const profileText = team ? teamProfileText : userProfileText;
const enabledPrivateDirectTemplates = useMemo(
() =>
(data?.templates ?? []).filter(
(template): template is DirectTemplate =>
template.directLink?.enabled === true && template.type !== TemplateType.PUBLIC,
),
[data],
);
const onProfileUpdate = async (data: TPublicProfileFormSchema) => {
if (team) {
await updateTeamProfile({
teamId: team.id,
...data,
});
} else {
await updateUserProfile(data);
}
if (data.enabled === undefined && !isPublicProfileVisible) {
setIsTooltipOpen(true);
}
};
const togglePublicProfileVisibility = async (isVisible: boolean) => {
setIsTooltipOpen(false);
if (isUpdating) {
return;
}
if (isVisible && !user.url) {
toast({
title: 'You must set a profile URL before enabling your public profile.',
variant: 'destructive',
});
return;
}
setIsPublicProfileVisible(isVisible);
try {
await onProfileUpdate({
enabled: isVisible,
});
} catch {
toast({
title: 'Something went wrong',
description: 'We were unable to set your public profile to public. Please try again.',
variant: 'destructive',
});
setIsPublicProfileVisible(!isVisible);
}
};
useEffect(() => {
setIsPublicProfileVisible(profile.enabled);
}, [profile.enabled]);
return (
<div className="max-w-2xl">
<SettingsHeader title={profileText.settingsTitle} subtitle={profileText.settingsSubtitle}>
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
<TooltipTrigger asChild>
<div
className={cn(
'text-muted-foreground/50 flex flex-row items-center justify-center space-x-2 text-xs',
{
'[&>*:first-child]:text-muted-foreground': !isPublicProfileVisible,
'[&>*:last-child]:text-muted-foreground': isPublicProfileVisible,
},
)}
>
<span>Hide</span>
<Switch
disabled={isUpdating}
checked={isPublicProfileVisible}
onCheckedChange={togglePublicProfileVisibility}
/>
<span>Show</span>
</div>
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-[40ch] space-y-2 py-2">
{isPublicProfileVisible ? (
<>
<p>
Profile is currently <strong>visible</strong>.
</p>
<p>Toggle the switch to hide your profile from the public.</p>
</>
) : (
<>
<p>
Profile is currently <strong>hidden</strong>.
</p>
<p>Toggle the switch to show your profile to the public.</p>
</>
)}
</TooltipContent>
</Tooltip>
</SettingsHeader>
<PublicProfileForm
profileUrl={team ? team.url : user.url}
teamUrl={team?.url}
profile={profile}
onProfileUpdate={onProfileUpdate}
/>
<div className="mt-4">
<SettingsHeader
title={profileText.templatesTitle}
subtitle={profileText.templatesSubtitle}
hideDivider={true}
className="mt-8 [&>*>h3]:text-base"
>
<ManagePublicTemplateDialog
directTemplates={enabledPrivateDirectTemplates}
trigger={<Button variant="outline">Link template</Button>}
/>
</SettingsHeader>
<div className="mt-6">
<PublicTemplatesDataTable />
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,209 @@
'use client';
import { useMemo, useState } from 'react';
import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import type { TemplateDirectLink } from '@documenso/prisma/client';
import { TemplateType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
type DirectTemplate = FindTemplateRow & {
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
};
export const PublicTemplatesDataTable = () => {
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const [, copy] = useCopyToClipboard();
const [publicTemplateDialogPayload, setPublicTemplateDialogPayload] = useState<{
step: 'MANAGE' | 'CONFIRM_DISABLE';
templateId: number;
} | null>(null);
const { data, isInitialLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery(
{
teamId: team?.id,
},
{
keepPreviousData: true,
},
);
const { directTemplates, publicDirectTemplates, privateDirectTemplates } = useMemo(() => {
const directTemplates = (data?.templates ?? []).filter(
(template): template is DirectTemplate => template.directLink?.enabled === true,
);
const publicDirectTemplates = directTemplates.filter(
(template) => template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
);
const privateDirectTemplates = directTemplates.filter(
(template) => template.directLink?.enabled === true && template.type === TemplateType.PRIVATE,
);
return {
directTemplates,
publicDirectTemplates,
privateDirectTemplates,
};
}, [data]);
const onCopyClick = async (token: string) =>
copy(formatDirectTemplatePath(token)).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The direct link has been copied to your clipboard',
});
});
return (
<div>
<div className="dark:divide-foreground/30 dark:border-foreground/30 mt-6 divide-y divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200">
{/* Loading and error handling states. */}
{publicDirectTemplates.length === 0 && (
<>
{isInitialLoading &&
Array(3)
.fill(0)
.map((_, index) => (
<div
key={index}
className="bg-background flex items-center justify-between gap-x-6 p-4"
>
<div className="flex gap-x-2">
<FileIcon className="text-muted-foreground/40 h-8 w-8" strokeWidth={1.5} />
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-48" />
</div>
</div>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
</div>
))}
{isLoadingError && (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
Unable to load your public profile templates at this time
<button
onClick={(e) => {
e.preventDefault();
void refetch();
}}
>
Click here to retry
</button>
</div>
)}
{!isInitialLoading && (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
No public profile templates found
<ManagePublicTemplateDialog
directTemplates={privateDirectTemplates}
trigger={
<button className="hover:text-muted-foreground/80 mt-1 text-xs">
Click here to get started
</button>
}
/>
</div>
)}
</>
)}
{/* Public templates list. */}
{publicDirectTemplates.map((template) => (
<div
key={template.id}
className="bg-background flex items-center justify-between gap-x-6 p-4"
>
<div className="flex gap-x-2">
<FileIcon
className="text-muted-foreground/40 h-8 w-8 flex-shrink-0"
strokeWidth={1.5}
/>
<div>
<p className="text-sm">{template.publicTitle}</p>
<p className="text-xs text-neutral-400">{template.publicDescription}</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="center" side="left">
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem onClick={() => void onCopyClick(template.directLink.token)}>
<LinkIcon className="mr-2 h-4 w-4" />
Copy sharable link
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setPublicTemplateDialogPayload({
step: 'MANAGE',
templateId: template.id,
});
}}
>
<EditIcon className="mr-2 h-4 w-4" />
Update
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
setPublicTemplateDialogPayload({
step: 'CONFIRM_DISABLE',
templateId: template.id,
})
}
>
<Trash2Icon className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
<ManagePublicTemplateDialog
directTemplates={directTemplates}
initialTemplateId={publicTemplateDialogPayload?.templateId}
initialStep={publicTemplateDialogPayload?.step}
isOpen={publicTemplateDialogPayload !== null}
onIsOpenChange={(value) => {
if (!value) {
setPublicTemplateDialogPayload(null);
}
}}
/>
</div>
);
};

View File

@ -97,6 +97,7 @@ export const DataTableActionDropdown = ({
<DeleteTemplateDialog
id={row.id}
teamId={row.teamId || undefined}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>

View File

@ -14,11 +14,17 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteTemplateDialogProps = {
id: number;
teamId?: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
export const DeleteTemplateDialog = ({
id,
teamId,
open,
onOpenChange,
}: DeleteTemplateDialogProps) => {
const router = useRouter();
const { toast } = useToast();
@ -67,7 +73,12 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={async () => deleteTemplate({ id })}>
<Button
type="button"
variant="destructive"
loading={isLoading}
onClick={async () => deleteTemplate({ id, teamId })}
>
Delete
</Button>
</DialogFooter>

View File

@ -383,7 +383,7 @@ export const TemplateDirectLinkDialog = ({
</div>
</div>
<DialogFooter className='mt-4'>
<DialogFooter className="mt-4">
<Button
type="button"
variant="destructive"

View File

@ -0,0 +1,32 @@
import React from 'react';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
import { NextAuthProvider } from '~/providers/next-auth';
import { ProfileHeader } from './profile-header';
type PublicProfileLayoutProps = {
children: React.ReactNode;
};
export default async function PublicProfileLayout({ children }: PublicProfileLayoutProps) {
const { user, session } = await getServerComponentSession();
// I wouldn't typically do this but it's better than the `let` statement
const teams = user && session ? await getTeams({ userId: user.id }) : undefined;
return (
<NextAuthProvider session={session}>
<div className="min-h-screen">
<ProfileHeader user={user} teams={teams} />
<main className="my-8 px-4 md:my-12 md:px-8">{children}</main>
</div>
<RefreshOnFocus />
</NextAuthProvider>
);
}

View File

@ -0,0 +1,32 @@
'use client';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export default function NotFound() {
return (
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
<div>
<p className="text-muted-foreground font-semibold">404 Profile not found</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
<p className="text-muted-foreground mt-4 text-sm">
The profile you are looking for could not be found.
</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button asChild className="w-32">
<Link href="/">
<ChevronLeft className="mr-2 h-4 w-4" />
Go Back
</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,194 @@
import Image from 'next/image';
import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import { FileIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getPublicProfileByUrl } from '@documenso/lib/server-only/profile/get-public-profile-by-url';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type PublicProfilePageProps = {
params: {
url: string;
};
};
const BADGE_DATA = {
Premium: {
imageSrc: '/static/premium-user-badge.svg',
name: 'Premium',
},
EarlySupporter: {
imageSrc: '/static/early-supporter-badge.svg',
name: 'Early supporter',
},
};
export default async function PublicProfilePage({ params }: PublicProfilePageProps) {
const { url: profileUrl } = params;
if (!profileUrl) {
redirect('/');
}
const publicProfile = await getPublicProfileByUrl({
profileUrl,
}).catch(() => null);
if (!publicProfile || !publicProfile.profile.enabled) {
notFound();
}
const { user } = await getServerComponentSession();
const { profile, templates } = publicProfile;
return (
<div className="flex flex-col items-center justify-center py-4 sm:py-32">
<div className="flex flex-col items-center">
<Avatar className="dark:border-border h-24 w-24 border-2 border-solid">
{publicProfile.avatarImageId && (
<AvatarImage
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${publicProfile.avatarImageId}`}
/>
)}
<AvatarFallback className="text-sm text-gray-400">
{extractInitials(publicProfile.name)}
</AvatarFallback>
</Avatar>
<div className="mt-4 flex flex-row items-center justify-center">
<h2 className="text-xl font-semibold md:text-2xl">{publicProfile.name}</h2>
{publicProfile.badge && (
<Tooltip>
<TooltipTrigger>
<Image
className="ml-2 flex items-center justify-center"
alt="Profile badge"
src={BADGE_DATA[publicProfile.badge.type].imageSrc}
height={24}
width={24}
/>
</TooltipTrigger>
<TooltipContent className="flex flex-row items-start py-2 !pl-3 !pr-3.5">
<Image
className="mt-0.5"
alt="Profile badge"
src={BADGE_DATA[publicProfile.badge.type].imageSrc}
height={24}
width={24}
/>
<div className="ml-2">
<p className="text-foreground text-base font-semibold">
{BADGE_DATA[publicProfile.badge.type].name}
</p>
<p className="text-muted-foreground mt-0.5 text-sm">
Since {DateTime.fromJSDate(publicProfile.badge.since).toFormat('LLL yy')}
</p>
</div>
</TooltipContent>
</Tooltip>
)}
</div>
<div className="text-muted-foreground mt-4 space-y-1">
{(profile.bio ?? '').split('\n').map((line, index) => (
<p
key={index}
className="max-w-[60ch] whitespace-pre-wrap break-words text-center text-sm"
>
{line}
</p>
))}
</div>
</div>
{templates.length === 0 && (
<div className="mt-4 w-full max-w-xl border-t pt-4">
<p className="text-muted-foreground max-w-[60ch] whitespace-pre-wrap break-words text-center text-sm leading-relaxed">
It looks like {publicProfile.name} hasn't added any documents to their profile yet.{' '}
{!user?.id && (
<span className="mt-2 inline-block">
While waiting for them to do so you can create your own Documenso account and get
started with document signing right away.
</span>
)}
{'userId' in profile && user?.id === profile.userId && (
<span className="mt-2 inline-block">
Go to your{' '}
<Link href="/settings/public-profile" className="underline">
public profile settings
</Link>{' '}
to add documents.
</span>
)}
</p>
</div>
)}
{templates.length > 0 && (
<div className="mt-8 w-full max-w-xl rounded-md border">
<Table className="w-full" overflowHidden>
<TableHeader>
<TableRow>
<TableHead className="w-full rounded-tl-md bg-neutral-50 dark:bg-neutral-700">
Documents
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((template) => (
<TableRow key={template.id}>
<TableCell className="text-muted-foreground flex flex-col justify-between overflow-hidden text-sm sm:flex-row">
<div className="flex flex-1 items-start justify-start gap-2">
<FileIcon
className="text-muted-foreground/40 h-8 w-8 flex-shrink-0"
strokeWidth={1.5}
/>
<div className="flex flex-1 flex-col gap-4 overflow-hidden md:flex-row md:items-start md:justify-between">
<div>
<p className="text-foreground text-sm font-semibold leading-none">
{template.publicTitle}
</p>
<p className="text-muted-foreground mt-1 line-clamp-3 max-w-[70ch] whitespace-normal text-xs">
{template.publicDescription}
</p>
</div>
<Button asChild className="w-20">
<Link href={formatDirectTemplatePath(template.directLink.token)}>
Sign
</Link>
</Button>
</div>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,86 @@
'use client';
import { useEffect, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { PlusIcon } from 'lucide-react';
import LogoIcon from '@documenso/assets/logo_icon.png';
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import type { User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
import { Logo } from '~/components/branding/logo';
type ProfileHeaderProps = {
user?: User | null;
teams?: GetTeamsResponse;
};
export const ProfileHeader = ({ user, teams = [] }: ProfileHeaderProps) => {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
if (user) {
return <AuthenticatedHeader user={user} teams={teams} />;
}
return (
<header
className={cn(
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
scrollY > 5 && 'border-b-border',
)}
>
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:px-8">
<Link
href="/"
className="focus-visible:ring-ring ring-offset-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
>
<Logo className="hidden h-6 w-auto sm:block" />
<Image
src={LogoIcon}
alt="Documenso Logo"
width={48}
height={48}
className="h-10 w-auto dark:invert sm:hidden"
/>
</Link>
<div className="flex flex-row items-center justify-center">
<p className="text-muted-foreground mr-4">
<span className="text-sm sm:hidden">Want your own public profile?</span>
<span className="hidden text-sm sm:block">
Like to have your own public profile with agreements?
</span>
</p>
<Button asChild variant="secondary">
<Link href="/signup">
<div className="hidden flex-row items-center sm:flex">
<PlusIcon className="mr-1 h-5 w-5" />
Create now
</div>
<span className="sm:hidden">Create</span>
</Link>
</Button>
</div>
</div>
</header>
);
};

View File

@ -4,6 +4,7 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { Field } from '@documenso/prisma/client';
import { type Recipient } from '@documenso/prisma/client';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
@ -47,6 +48,8 @@ export const DirectTemplatePageView = ({
const [step, setStep] = useState<DirectTemplateStep>('configure');
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
const recipientRoleDescription = RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role];
const directTemplateFlow: Record<DirectTemplateStep, DocumentFlowStep> = {
configure: {
title: 'General',
@ -54,8 +57,8 @@ export const DirectTemplatePageView = ({
stepIndex: 1,
},
sign: {
title: 'Sign document',
description: 'Sign the document to complete the process.',
title: `${recipientRoleDescription.actionVerb} document`,
description: `${recipientRoleDescription.actionVerb} the document to complete the process.`,
stepIndex: 2,
},
};

View File

@ -10,6 +10,7 @@ import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
@ -70,8 +71,14 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
userId: user?.id,
});
let recipientHasAccount: boolean | null = null;
if (!isDocumentAccessValid) {
return <SigningAuthPageView email={recipient.email} />;
recipientHasAccount = await getUserByEmail({ email: recipient?.email })
.then((user) => !!user)
.catch(() => false);
return <SigningAuthPageView email={recipient.email} emailHasAccount={!!recipientHasAccount} />;
}
await viewedDocument({

View File

@ -11,9 +11,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type SigningAuthPageViewProps = {
email: string;
emailHasAccount?: boolean;
};
export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => {
export const SigningAuthPageView = ({ email, emailHasAccount }: SigningAuthPageViewProps) => {
const { toast } = useToast();
const [isSigningOut, setIsSigningOut] = useState(false);
@ -30,7 +31,9 @@ export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => {
});
await signOut({
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`,
callbackUrl: emailHasAccount
? `/signin?email=${encodeURIComponent(encryptedEmail)}`
: `/signup?email=${encodeURIComponent(encryptedEmail)}`,
});
} catch {
toast({
@ -59,7 +62,7 @@ export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => {
onClick={async () => handleChangeAccount(email)}
loading={isSigningOut}
>
Login
{emailHasAccount ? 'Login' : 'Sign up'}
</Button>
</div>
</div>

View File

@ -13,6 +13,7 @@ import { AddTeamEmailDialog } from '~/components/(teams)/dialogs/add-team-email-
import { DeleteTeamDialog } from '~/components/(teams)/dialogs/delete-team-dialog';
import { TransferTeamDialog } from '~/components/(teams)/dialogs/transfer-team-dialog';
import { UpdateTeamForm } from '~/components/(teams)/forms/update-team-form';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { TeamEmailDropdown } from './team-email-dropdown';
import { TeamTransferStatus } from './team-transfer-status';
@ -35,7 +36,7 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro
return (
<div>
<SettingsHeader title="Team Profile" subtitle="Here you can edit your team's details." />
<SettingsHeader title="General settings" subtitle="Here you can edit your team's details." />
<TeamTransferStatus
className="mb-4"
@ -44,6 +45,8 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro
transferVerification={team.transferVerification}
/>
<AvatarImageForm className="mb-8" team={team} user={session.user} />
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
<section className="mt-6 space-y-6">

View File

@ -0,0 +1,28 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
import { PublicProfilePageView } from '~/app/(dashboard)/settings/public-profile/public-profile-page-view';
export type TeamsSettingsPublicProfilePageProps = {
params: {
teamUrl: string;
};
};
export default async function TeamsSettingsPublicProfilePage({
params,
}: TeamsSettingsPublicProfilePageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const { profile } = await getTeamPublicProfile({
userId: user.id,
teamId: team.id,
});
return <PublicProfilePageView user={user} team={team} profile={profile} />;
}

View File

@ -7,6 +7,7 @@ import { motion } from 'framer-motion';
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
import { signOut } from 'next-auth/react';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
@ -99,6 +100,9 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
>
<AvatarWithText
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${
selectedTeam ? selectedTeam.avatarImageId : user.avatarImageId
}`}
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
primaryText={selectedTeam ? selectedTeam.name : user.name}
secondaryText={formatSecondaryAvatarText(selectedTeam)}
@ -122,6 +126,11 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
<DropdownMenuItem asChild>
<Link href={formatRedirectUrlOnSwitch()}>
<AvatarWithText
avatarSrc={
user.avatarImageId
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${user.avatarImageId}`
: undefined
}
avatarFallback={formatAvatarFallback()}
primaryText={user.name}
secondaryText={formatSecondaryAvatarText()}
@ -180,10 +189,15 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
href={formatRedirectUrlOnSwitch(team.url)}
>
<AvatarWithText
avatarSrc={
team.avatarImageId
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`
: undefined
}
avatarFallback={formatAvatarFallback(team.name)}
primaryText={team.name}
secondaryText={
<div className="relative">
<div className="relative w-full">
<motion.span
className="overflow-hidden"
variants={{

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@ -19,6 +19,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
const isPublicProfileEnabled = getFlag('app_public_profile');
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
@ -35,6 +36,21 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
{isPublicProfileEnabled && (
<Link href="/settings/public-profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
Public Profile
</Button>
</Link>
)}
<Link href="/settings/teams">
<Button
variant="ghost"

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@ -19,6 +19,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
const isPublicProfileEnabled = getFlag('app_public_profile');
return (
<div
@ -38,6 +39,21 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
{isPublicProfileEnabled && (
<Link href="/settings/public-profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
Public Profile
</Button>
</Link>
)}
<Link href="/settings/teams">
<Button
variant="ghost"

View File

@ -158,7 +158,6 @@ export const CreateTeamCheckoutDialog = ({
<Button
type="submit"
disabled={selectedPrice.interval === 'yearly'}
loading={isCreatingCheckout}
onClick={async () =>
createCheckout({
@ -167,7 +166,7 @@ export const CreateTeamCheckoutDialog = ({
})
}
>
{selectedPrice.interval === 'monthly' ? 'Checkout' : 'Coming soon'}
Checkout
</Button>
</DialogFooter>
</div>

View File

@ -5,8 +5,9 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import { Braces, CreditCard, Settings, Users, Webhook } from 'lucide-react';
import { Braces, CreditCard, Globe2Icon, Settings, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -17,9 +18,14 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const pathname = usePathname();
const params = useParams();
const { getFlag } = useFeatureFlags();
const isPublicProfileEnabled = getFlag('app_public_profile');
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
const settingsPath = `/t/${teamUrl}/settings`;
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
@ -37,6 +43,21 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
{isPublicProfileEnabled && (
<Link href={publicProfilePath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
Public Profile
</Button>
</Link>
)}
<Link href={membersPath}>
<Button
variant="ghost"

View File

@ -5,8 +5,9 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import { Braces, CreditCard, Key, User, Webhook } from 'lucide-react';
import { Braces, CreditCard, Globe2Icon, Key, User, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -17,9 +18,14 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
const pathname = usePathname();
const params = useParams();
const { getFlag } = useFeatureFlags();
const isPublicProfileEnabled = getFlag('app_public_profile');
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
const settingsPath = `/t/${teamUrl}/settings`;
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
@ -45,6 +51,21 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
{isPublicProfileEnabled && (
<Link href={publicProfilePath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
Public Profile
</Button>
</Link>
)}
<Link href={membersPath}>
<Button
variant="ghost"

View File

@ -2,6 +2,7 @@
import { useState } from 'react';
import { EyeOffIcon } from 'lucide-react';
import { P, match } from 'ts-pattern';
import {
@ -10,19 +11,19 @@ import {
} from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { CompletedField } from '@documenso/lib/types/fields';
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { DocumentMeta } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import { FieldType, SigningStatus } from '@documenso/prisma/client';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PopoverHover } from '@documenso/ui/primitives/popover';
export type DocumentReadOnlyFieldsProps = {
fields: CompletedField[];
fields: DocumentField[];
documentMeta?: DocumentMeta;
};
@ -53,56 +54,71 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
</Avatar>
}
contentProps={{
className: 'flex w-fit flex-col py-2.5 text-sm',
className: 'relative flex w-fit flex-col p-2.5 text-sm',
}}
>
<p>
<span className="font-semibold">
{field.Recipient.name
? `${field.Recipient.name} (${field.Recipient.email})`
: field.Recipient.email}{' '}
</span>
inserted a {FRIENDLY_FIELD_TYPE[field.type].toLowerCase()}
<p className="font-semibold">
{field.Recipient.signingStatus === SigningStatus.SIGNED ? 'Signed' : 'Pending'}{' '}
{FRIENDLY_FIELD_TYPE[field.type].toLowerCase()} field
</p>
<Button
variant="outline"
className="mt-2.5 h-6 text-xs focus:outline-none focus-visible:ring-0"
<p className="text-muted-foreground text-xs">
{field.Recipient.name
? `${field.Recipient.name} (${field.Recipient.email})`
: field.Recipient.email}{' '}
</p>
<button
className="absolute right-0 top-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
onClick={() => handleHideField(field.secondaryId)}
title="Hide field"
>
Hide field
</Button>
<EyeOffIcon className="h-3 w-3" />
</button>
</PopoverHover>
</div>
<div className="text-muted-foreground break-all text-sm">
{match(field)
.with({ type: FieldType.SIGNATURE }, (field) =>
field.Signature?.signatureImageAsBase64 ? (
<img
src={field.Signature.signatureImageAsBase64}
alt="Signature"
className="h-full w-full object-contain dark:invert"
/>
) : (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{field.Signature?.typedSignature}
</p>
),
)
.with(
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
() => field.customText,
)
.with({ type: FieldType.DATE }, () =>
convertToLocalSystemFormat(
field.customText,
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
),
)
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
.exhaustive()}
{field.Recipient.signingStatus === SigningStatus.SIGNED &&
match(field)
.with({ type: FieldType.SIGNATURE }, (field) =>
field.Signature?.signatureImageAsBase64 ? (
<img
src={field.Signature.signatureImageAsBase64}
alt="Signature"
className="h-full w-full object-contain dark:invert"
/>
) : (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{field.Signature?.typedSignature}
</p>
),
)
.with(
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
() => field.customText,
)
.with({ type: FieldType.DATE }, () =>
convertToLocalSystemFormat(
field.customText,
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
),
)
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
.exhaustive()}
{field.Recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<p
className={cn('text-muted-foreground text-lg duration-200', {
'font-signature sm:text-xl md:text-2xl lg:text-3xl':
field.type === FieldType.SIGNATURE ||
field.type === FieldType.FREE_SIGNATURE,
})}
>
{FRIENDLY_FIELD_TYPE[field.type]}
</p>
)}
</div>
</FieldRootContainer>
),

View File

@ -0,0 +1,189 @@
'use client';
import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { ErrorCode, useDropzone } from 'react-dropzone';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { Team, User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZAvatarImageFormSchema = z.object({
bytes: z.string().nullish(),
});
export type TAvatarImageFormSchema = z.infer<typeof ZAvatarImageFormSchema>;
export type AvatarImageFormProps = {
className?: string;
user: User;
team?: Team;
};
export const AvatarImageForm = ({ className, user, team }: AvatarImageFormProps) => {
const { toast } = useToast();
const router = useRouter();
const { mutateAsync: setProfileImage } = trpc.profile.setProfileImage.useMutation();
const initials = extractInitials(team?.name || user.name || '');
const hasAvatarImage = useMemo(() => {
if (team) {
return team.avatarImageId !== null;
}
return user.avatarImageId !== null;
}, [team, user.avatarImageId]);
const avatarImageId = team ? team.avatarImageId : user.avatarImageId;
const form = useForm<TAvatarImageFormSchema>({
values: {
bytes: null,
},
resolver: zodResolver(ZAvatarImageFormSchema),
});
const { getRootProps, getInputProps } = useDropzone({
maxSize: 1024 * 1024,
accept: {
'image/*': ['.png', '.jpg', '.jpeg'],
},
multiple: false,
onDropAccepted: ([file]) => {
void file.arrayBuffer().then((buffer) => {
const contents = base64.encode(new Uint8Array(buffer));
form.setValue('bytes', contents);
void form.handleSubmit(onFormSubmit)();
});
},
onDropRejected: ([file]) => {
form.setError('bytes', {
type: 'onChange',
message: match(file.errors[0].code)
.with(ErrorCode.FileTooLarge, () => 'Uploaded file is too large')
.with(ErrorCode.FileTooSmall, () => 'Uploaded file is too small')
.with(ErrorCode.FileInvalidType, () => 'Uploaded file not an allowed file type')
.otherwise(() => 'An unknown error occurred'),
});
},
});
const onFormSubmit = async (data: TAvatarImageFormSchema) => {
try {
await setProfileImage({
bytes: data.bytes,
teamId: team?.id,
});
toast({
title: 'Avatar Updated',
description: 'Your avatar has been updated successfully.',
duration: 5000,
});
router.refresh();
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to update the avatar. Please try again later.',
});
}
}
};
return (
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
// onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="bytes"
render={() => (
<FormItem>
<FormLabel>Avatar</FormLabel>
<FormControl>
<div className="flex items-center gap-8">
<div className="relative">
<Avatar className="h-16 w-16 border-2 border-solid">
{avatarImageId && (
<AvatarImage
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${avatarImageId}`}
/>
)}
<AvatarFallback className="text-sm text-gray-400">
{initials}
</AvatarFallback>
</Avatar>
{hasAvatarImage && (
<button
type="button"
className="bg-background/70 text-destructive absolute inset-0 flex cursor-pointer items-center justify-center text-xs opacity-0 transition-opacity hover:opacity-100"
disabled={form.formState.isSubmitting}
onClick={() => void onFormSubmit({ bytes: null })}
>
Remove
</button>
)}
</div>
<Button
type="button"
variant="secondary"
size="sm"
{...getRootProps()}
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}
>
Upload Avatar
<input {...getInputProps()} />
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,265 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { motion } from 'framer-motion';
import { AnimatePresence } from 'framer-motion';
import { CheckSquareIcon, CopyIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { formatUserProfilePath } from '@documenso/lib/utils/public-profiles';
import type { TeamProfile, UserProfile } from '@documenso/prisma/client';
import {
MAX_PROFILE_BIO_LENGTH,
ZUpdatePublicProfileMutationSchema,
} from '@documenso/trpc/server/profile-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZPublicProfileFormSchema = ZUpdatePublicProfileMutationSchema.pick({
bio: true,
enabled: true,
url: true,
});
export type TPublicProfileFormSchema = z.infer<typeof ZPublicProfileFormSchema>;
export type PublicProfileFormProps = {
className?: string;
profileUrl?: string | null;
teamUrl?: string;
onProfileUpdate: (data: TPublicProfileFormSchema) => Promise<unknown>;
profile: UserProfile | TeamProfile;
};
export const PublicProfileForm = ({
className,
profileUrl,
profile,
teamUrl,
onProfileUpdate,
}: PublicProfileFormProps) => {
const { toast } = useToast();
const [, copy] = useCopyToClipboard();
const [copiedTimeout, setCopiedTimeout] = useState<NodeJS.Timeout | null>(null);
const form = useForm<TPublicProfileFormSchema>({
values: {
url: profileUrl ?? '',
bio: profile?.bio ?? '',
},
resolver: zodResolver(ZPublicProfileFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async (data: TPublicProfileFormSchema) => {
try {
await onProfileUpdate(data);
toast({
title: 'Success',
description: 'Your public profile has been updated.',
duration: 5000,
});
form.reset({
url: data.url,
bio: data.bio,
});
} catch (err) {
const error = AppError.parseError(err);
switch (error.code) {
case AppErrorCode.PREMIUM_PROFILE_URL:
case AppErrorCode.PROFILE_URL_TAKEN:
form.setError('url', {
type: 'manual',
message: error.message,
});
break;
default:
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to update your public profile. Please try again later.',
});
}
}
};
const onCopy = async () => {
await copy(formatUserProfilePath(form.getValues('url') ?? '')).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The profile link has been copied to your clipboard',
});
});
if (copiedTimeout) {
clearTimeout(copiedTimeout);
}
setCopiedTimeout(
setTimeout(() => {
setCopiedTimeout(null);
}, 2000),
);
};
return (
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile URL</FormLabel>
<FormControl>
<Input {...field} disabled={field.disabled || teamUrl !== undefined} />
</FormControl>
{teamUrl && (
<p className="text-muted-foreground text-xs">
You can update the profile URL by updating the team URL in the general settings
page.
</p>
)}
<div className="h-8">
{!form.formState.errors.url && (
<div className="text-muted-foreground h-8 text-sm">
{field.value ? (
<div>
<Button
type="button"
variant="none"
className="h-7 rounded bg-neutral-50 pl-2 pr-0.5 font-normal dark:border dark:border-neutral-500 dark:bg-neutral-600"
onClick={async () => onCopy()}
>
<p>
{formatUserProfilePath('').replace(/https?:\/\//, '')}
<span className="font-semibold">{field.value}</span>
</p>
<div className="ml-1 flex h-6 w-6 items-center justify-center rounded transition-all hover:bg-neutral-200 hover:active:bg-neutral-300 dark:hover:bg-neutral-500 dark:hover:active:bg-neutral-400">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={copiedTimeout ? 'copied' : 'copy'}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.1 } }}
className="absolute"
>
{copiedTimeout ? (
<CheckSquareIcon className="h-3.5 w-3.5" />
) : (
<CopyIcon className="h-3.5 w-3.5" />
)}
</motion.div>
</AnimatePresence>
</div>
</Button>
</div>
) : (
<p>A unique URL to access your profile</p>
)}
</div>
)}
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="bio"
render={({ field }) => {
const remaningLength = MAX_PROFILE_BIO_LENGTH - (field.value || '').length;
const pluralWord = Math.abs(remaningLength) === 1 ? 'character' : 'characters';
return (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={teamUrl ? 'Write about the team' : 'Write about yourself'}
/>
</FormControl>
{!form.formState.errors.bio && (
<p className="text-muted-foreground text-sm">
{remaningLength >= 0
? `${remaningLength} ${pluralWord} remaining`
: `${Math.abs(remaningLength)} ${pluralWord} over the limit`}
</p>
)}
<FormMessage />
</FormItem>
);
}}
/>
<div className="flex flex-row justify-end space-x-4">
<AnimatePresence>
{form.formState.isDirty && (
<motion.div
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
<Button type="button" variant="secondary" onClick={() => form.reset()}>
Reset
</Button>
</motion.div>
)}
</AnimatePresence>
<Button
type="submit"
className="transition-opacity"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
Update
</Button>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,429 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { P, match } from 'ts-pattern';
import { z } from 'zod';
import type { Template, TemplateDirectLink } from '@documenso/prisma/client';
import { TemplateType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import {
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,
MAX_TEMPLATE_PUBLIC_TITLE_LENGTH,
} from '@documenso/trpc/server/template-router/schema';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { LocaleDate } from '../formatter/locale-date';
export type ManagePublicTemplateDialogProps = {
directTemplates: (Template & {
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
})[];
initialTemplateId?: number | null;
initialStep?: ProfileTemplateStep;
trigger?: React.ReactNode;
isOpen?: boolean;
onIsOpenChange?: (value: boolean) => unknown;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdatePublicTemplateFormSchema = z.object({
publicTitle: z
.string()
.min(1, { message: 'Title is required' })
.max(MAX_TEMPLATE_PUBLIC_TITLE_LENGTH, {
message: `Title cannot be longer than ${MAX_TEMPLATE_PUBLIC_TITLE_LENGTH} characters`,
}),
publicDescription: z
.string()
.min(1, { message: 'Description is required' })
.max(MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH, {
message: `Description cannot be longer than ${MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH} characters`,
}),
});
type TUpdatePublicTemplateFormSchema = z.infer<typeof ZUpdatePublicTemplateFormSchema>;
type ProfileTemplateStep = 'SELECT_TEMPLATE' | 'MANAGE' | 'CONFIRM_DISABLE';
export const ManagePublicTemplateDialog = ({
directTemplates,
trigger,
initialTemplateId = null,
initialStep = 'SELECT_TEMPLATE',
isOpen = false,
onIsOpenChange,
...props
}: ManagePublicTemplateDialogProps) => {
const { toast } = useToast();
const [open, onOpenChange] = useState(isOpen);
const team = useOptionalCurrentTeam();
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(initialTemplateId);
const [currentStep, setCurrentStep] = useState<ProfileTemplateStep>(() => {
if (initialStep) {
return initialStep;
}
return selectedTemplateId ? 'MANAGE' : 'SELECT_TEMPLATE';
});
const form = useForm({
resolver: zodResolver(ZUpdatePublicTemplateFormSchema),
defaultValues: {
publicTitle: '',
publicDescription: '',
},
});
const { mutateAsync: updateTemplateSettings, isLoading: isUpdatingTemplateSettings } =
trpc.template.updateTemplateSettings.useMutation();
const setTemplateToPrivate = async (templateId: number) => {
try {
await updateTemplateSettings({
templateId,
teamId: team?.id,
data: {
type: TemplateType.PRIVATE,
},
});
toast({
title: 'Success',
description: 'Template has been removed from your public profile.',
duration: 5000,
});
handleOnOpenChange(false);
} catch {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to remove this template from your profile. Please try again later.',
});
}
};
const onFormSubmit = async ({
publicTitle,
publicDescription,
}: TUpdatePublicTemplateFormSchema) => {
if (!selectedTemplateId) {
return;
}
try {
await updateTemplateSettings({
templateId: selectedTemplateId,
teamId: team?.id,
data: {
type: TemplateType.PUBLIC,
publicTitle,
publicDescription,
},
});
toast({
title: 'Success',
description: 'Template has been updated.',
duration: 5000,
});
onOpenChange(false);
} catch {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to update the template. Please try again later.',
});
}
};
const selectedTemplate = useMemo(
() => directTemplates.find((template) => template.id === selectedTemplateId),
[directTemplates, selectedTemplateId],
);
const onManageStep = () => {
if (!selectedTemplate) {
return;
}
form.reset({
publicTitle: selectedTemplate.publicTitle,
publicDescription: selectedTemplate.publicDescription,
});
setCurrentStep('MANAGE');
};
const isLoading = isUpdatingTemplateSettings || form.formState.isSubmitting;
useEffect(() => {
const initialTemplate = directTemplates.find((template) => template.id === initialTemplateId);
if (initialTemplate) {
setSelectedTemplateId(initialTemplate.id);
form.reset({
publicTitle: initialTemplate.publicTitle,
publicDescription: initialTemplate.publicDescription,
});
} else {
setSelectedTemplateId(null);
}
const step = initialStep || (selectedTemplateId ? 'MANAGE' : 'SELECT_TEMPLATE');
setCurrentStep(step);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialTemplateId, initialStep, open, isOpen]);
const handleOnOpenChange = (value: boolean) => {
if (isLoading || typeof value !== 'boolean') {
return;
}
onOpenChange(value);
onIsOpenChange?.(value);
};
return (
<Dialog {...props} open={isOpen || open} onOpenChange={handleOnOpenChange}>
<fieldset disabled={isLoading} className="relative flex-shrink-0">
<DialogTrigger asChild>{trigger}</DialogTrigger>
<AnimateGenericFadeInOut motionKey={currentStep}>
{match({ templateId: selectedTemplateId, currentStep })
.with({ currentStep: 'SELECT_TEMPLATE' }, () => (
<DialogContent>
<DialogHeader>
<DialogTitle>{team?.name || 'Your'} direct signing templates</DialogTitle>
<DialogDescription>
Select a template you'd like to display on your {team && `team's`} public
profile
</DialogDescription>
</DialogHeader>
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>Template</TableHead>
<TableHead>Created</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{directTemplates.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="h-16 text-center">
<p className="text-muted-foreground">No valid direct templates found</p>
</TableCell>
</TableRow>
)}
{directTemplates.map((row) => (
<TableRow
className="w-full cursor-pointer"
key={row.id}
onClick={() => setSelectedTemplateId(row.id)}
>
<TableCell className="text-muted-foreground max-w-[30ch] text-sm">
{row.title}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
<LocaleDate date={row.createdAt} />
</TableCell>
<TableCell>
{selectedTemplateId === row.id ? (
<CheckCircle2Icon className="h-5 w-5 text-neutral-600 dark:text-neutral-200" />
) : (
<CircleIcon className="h-5 w-5 text-neutral-300 dark:text-neutral-600" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<Button
type="button"
disabled={selectedTemplateId === null}
onClick={() => onManageStep()}
>
Continue
</Button>
</DialogFooter>
</DialogContent>
))
.with({ templateId: P.number, currentStep: 'MANAGE' }, () => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>Configure template</DialogTitle>
<DialogDescription>Manage details for this public template</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
className="flex h-full flex-col space-y-4"
onSubmit={form.handleSubmit(onFormSubmit)}
>
<FormField
control={form.control}
name="publicTitle"
render={({ field }) => (
<FormItem>
<FormLabel required>Title</FormLabel>
<FormControl>
<Input placeholder="The public name for your template" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="publicDescription"
render={({ field }) => {
const remaningLength =
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH - (field.value || '').length;
const pluralWord =
Math.abs(remaningLength) === 1 ? 'character' : 'characters';
return (
<FormItem>
<FormLabel required>Description</FormLabel>
<FormControl>
<Textarea
placeholder="The public description that will be displayed with this template"
{...field}
/>
</FormControl>
{!form.formState.errors.publicDescription && (
<p className="text-muted-foreground text-sm">
{remaningLength >= 0
? `${remaningLength} ${pluralWord} remaining`
: `${Math.abs(remaningLength)} ${pluralWord} over the limit`}
</p>
)}
<FormMessage />
</FormItem>
);
}}
/>
<DialogFooter>
{selectedTemplate?.type === TemplateType.PUBLIC && (
<Button
variant="destructive"
className="mr-auto w-full sm:w-auto"
onClick={() => setCurrentStep('CONFIRM_DISABLE')}
>
Disable
</Button>
)}
<DialogClose asChild>
<Button variant="secondary">Close</Button>
</DialogClose>
<Button type="submit" loading={isUpdatingTemplateSettings}>
Update
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
))
.with({ templateId: P.number, currentStep: 'CONFIRM_DISABLE' }, ({ templateId }) => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
The template will be removed from your profile
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
loading={isUpdatingTemplateSettings}
onClick={() => void setTemplateToPrivate(templateId)}
>
Confirm
</Button>
</DialogFooter>
</DialogContent>
))
.otherwise(() => null)}
</AnimateGenericFadeInOut>
</fieldset>
</Dialog>
);
};

View File

@ -0,0 +1,34 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getAvatarImage } from '@documenso/lib/server-only/profile/get-avatar-image';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({
status: 'error',
message: 'Method not allowed',
});
}
const { id } = req.query;
if (typeof id !== 'string') {
return res.status(400).json({
status: 'error',
message: 'Missing id',
});
}
const result = await getAvatarImage({ id });
if (!result) {
return res.status(404).json({
status: 'error',
message: 'Not found',
});
}
res.setHeader('Content-Type', result.contentType);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.send(result.content);
}

View File

@ -0,0 +1,10 @@
import { jobsClient } from '@documenso/lib/jobs/client';
export const config = {
maxDuration: 300,
api: {
bodyParser: false,
},
};
export default jobsClient.getApiHandler();

View File

@ -15,8 +15,7 @@ WORKDIR /app
COPY . .
RUN TURBO_VERSION="$(npm list --package-lock-only --json turbo | jq -r '.dependencies.turbo.version')"
RUN npm install -g "turbo@$TURBO_VERSION"
RUN npm install -g "turbo@^1.9.3"
# Outputs to the /out folder
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
@ -66,8 +65,7 @@ COPY --from=builder /app/out/full/ .
# Finally copy the turbo.json file so that we can run turbo commands
COPY turbo.json turbo.json
RUN TURBO_VERSION="$(npm list --package-lock-only --json turbo | jq -r '.dependencies.turbo.version')"
RUN npm install -g "turbo@$TURBO_VERSION"
RUN npm install -g "turbo@^1.9.3"
RUN turbo run build --filter=@documenso/web...

View File

@ -4,6 +4,13 @@ services:
database:
image: postgres:15
container_name: database
volumes:
- documenso_database:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
interval: 10s
timeout: 5s
retries: 5
environment:
- POSTGRES_USER=documenso
- POSTGRES_PASSWORD=password
@ -33,5 +40,43 @@ services:
entrypoint: sh
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
triggerdotdev:
image: ghcr.io/triggerdotdev/trigger.dev:latest
container_name: triggerdotdev
environment:
- LOGIN_ORIGIN=http://localhost:3030
- APP_ORIGIN=http://localhost:3030
- PORT=3030
- REMIX_APP_PORT=3030
- MAGIC_LINK_SECRET=secret
- SESSION_SECRET=secret
- ENCRYPTION_KEY=deadbeefcafefeed
- DATABASE_URL=postgresql://trigger:password@triggerdotdev_database:5432/trigger
- DIRECT_URL=postgresql://trigger:password@triggerdotdev_database:5432/trigger
- RUNTIME_PLATFORM=docker-compose
ports:
- 3030:3030
depends_on:
- triggerdotdev_database
triggerdotdev_database:
container_name: triggerdotdev_database
image: postgres:15
volumes:
- triggerdotdev_database:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
interval: 10s
timeout: 5s
retries: 5
environment:
- POSTGRES_USER=trigger
- POSTGRES_PASSWORD=password
- POSTGRES_DB=trigger
ports:
- 54321:5432
volumes:
minio:
documenso_database:
triggerdotdev_database:

5704
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -24,15 +24,19 @@
"prisma:studio": "npm run with:env -- npm run prisma:studio -w @documenso/prisma",
"with:env": "dotenv -e .env -e .env.local --",
"reset:hard": "npm run clean && npm i && npm run prisma:generate",
"precommit": "npm install && git add package.json package-lock.json"
"precommit": "npm install && git add package.json package-lock.json",
"trigger:dev": "npm run with:env -- npx trigger-cli dev --handler-path=\"/api/jobs\"",
"inngest:dev": "inngest dev -u http://localhost:3000/api/jobs"
},
"packageManager": "npm@10.7.0",
"engines": {
"npm": ">=8.6.0",
"npm": ">=10.7.0",
"node": ">=18.0.0"
},
"devDependencies": {
"@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0",
"@trigger.dev/cli": "^2.3.18",
"dotenv": "^16.3.1",
"dotenv-cli": "^7.3.0",
"eslint": "^8.40.0",
@ -51,7 +55,9 @@
],
"dependencies": {
"@documenso/pdf-sign": "^0.1.0",
"next-runtime-env": "^3.2.0"
"inngest-cli": "^0.29.1",
"next-runtime-env": "^3.2.0",
"react": "18.2.0"
},
"overrides": {
"next-auth": {
@ -59,6 +65,10 @@
},
"next-contentlayer": {
"next": "14.0.3"
}
},
"react": "18.2.0"
},
"trigger.dev": {
"endpointId": "documenso-app"
}
}

View File

@ -15,10 +15,15 @@ import {
ZGenerateDocumentFromTemplateMutationResponseSchema,
ZGenerateDocumentFromTemplateMutationSchema,
ZGetDocumentsQuerySchema,
ZGetTemplatesQuerySchema,
ZNoBodyMutationSchema,
ZSendDocumentForSigningMutationSchema,
ZSuccessfulDeleteTemplateResponseSchema,
ZSuccessfulDocumentResponseSchema,
ZSuccessfulFieldResponseSchema,
ZSuccessfulGetDocumentResponseSchema,
ZSuccessfulGetTemplateResponseSchema,
ZSuccessfulGetTemplatesResponseSchema,
ZSuccessfulRecipientResponseSchema,
ZSuccessfulResponseSchema,
ZSuccessfulSigningResponseSchema,
@ -77,6 +82,41 @@ export const ApiContractV1 = c.router(
summary: 'Upload a new document and get a presigned URL',
},
deleteTemplate: {
method: 'DELETE',
path: '/api/v1/templates/:id',
body: ZNoBodyMutationSchema,
responses: {
200: ZSuccessfulDeleteTemplateResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Delete a template',
},
getTemplate: {
method: 'GET',
path: '/api/v1/templates/:id',
responses: {
200: ZSuccessfulGetTemplateResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Get a single template',
},
getTemplates: {
method: 'GET',
path: '/api/v1/templates',
query: ZGetTemplatesQuerySchema,
responses: {
200: ZSuccessfulGetTemplatesResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Get all templates',
},
createDocumentFromTemplate: {
method: 'POST',
path: '/api/v1/templates/:templateId/create-document',

View File

@ -24,6 +24,9 @@ import { updateRecipient } from '@documenso/lib/server-only/recipient/update-rec
import type { CreateDocumentFromTemplateResponse } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
@ -277,6 +280,73 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
deleteTemplate: authenticatedMiddleware(async (args, user, team) => {
const { id: templateId } = args.params;
try {
const deletedTemplate = await deleteTemplate({
id: Number(templateId),
userId: user.id,
teamId: team?.id,
});
return {
status: 200,
body: deletedTemplate,
};
} catch (err) {
return {
status: 404,
body: {
message: 'Template not found',
},
};
}
}),
getTemplate: authenticatedMiddleware(async (args, user, team) => {
const { id: templateId } = args.params;
try {
const template = await getTemplateById({
id: Number(templateId),
userId: user.id,
teamId: team?.id,
});
return {
status: 200,
body: template,
};
} catch (err) {
return AppError.toRestAPIError(err);
}
}),
getTemplates: authenticatedMiddleware(async (args, user, team) => {
const page = Number(args.query.page) || 1;
const perPage = Number(args.query.perPage) || 10;
try {
const { templates, totalPages } = await findTemplates({
page,
perPage,
userId: user.id,
teamId: team?.id,
});
return {
status: 200,
body: {
templates,
totalPages,
},
};
} catch (err) {
return AppError.toRestAPIError(err);
}
}),
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => {
const { body, params } = args;

View File

@ -2,13 +2,17 @@ import { z } from 'zod';
import { ZUrlSchema } from '@documenso/lib/schemas/common';
import {
DocumentDataType,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
TemplateType,
} from '@documenso/prisma/client';
export const ZNoBodyMutationSchema = null;
/**
* Documents
*/
@ -315,3 +319,106 @@ export const ZUnsuccessfulResponseSchema = z.object({
});
export type TUnsuccessfulResponseSchema = z.infer<typeof ZUnsuccessfulResponseSchema>;
export const ZTemplateMetaSchema = z.object({
id: z.string(),
subject: z.string().nullish(),
message: z.string().nullish(),
timezone: z.string().nullish(),
dateFormat: z.string().nullish(),
templateId: z.number(),
redirectUrl: z.string().nullish(),
});
export const ZTemplateSchema = z.object({
id: z.number(),
type: z.nativeEnum(TemplateType),
title: z.string(),
userId: z.number(),
teamId: z.number().nullish(),
templateDocumentDataId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
});
export const ZRecipientSchema = z.object({
id: z.number(),
documentId: z.number().nullish(),
templateId: z.number().nullish(),
email: z.string().email().min(1),
name: z.string(),
token: z.string(),
documentDeletedAt: z.date().nullish(),
expired: z.date().nullish(),
signedAt: z.date().nullish(),
authOptions: z.unknown(),
role: z.nativeEnum(RecipientRole),
readStatus: z.nativeEnum(ReadStatus),
signingStatus: z.nativeEnum(SigningStatus),
sendStatus: z.nativeEnum(SendStatus),
});
export const ZFieldSchema = z.object({
id: z.number(),
secondaryId: z.string(),
documentId: z.number().nullish(),
templateId: z.number().nullish(),
recipientId: z.number(),
type: z.nativeEnum(FieldType),
page: z.number(),
positionX: z.unknown(),
positionY: z.unknown(),
width: z.unknown(),
height: z.unknown(),
customText: z.string(),
inserted: z.boolean(),
});
export const ZTemplateWithDataSchema = ZTemplateSchema.extend({
templateMeta: ZTemplateMetaSchema.nullish(),
directLink: z
.object({
token: z.string(),
enabled: z.boolean(),
})
.nullable(),
templateDocumentData: z.object({
id: z.string(),
type: z.nativeEnum(DocumentDataType),
data: z.string(),
}),
Field: ZFieldSchema.pick({
id: true,
recipientId: true,
type: true,
page: true,
positionX: true,
positionY: true,
width: true,
height: true,
}).array(),
Recipient: ZRecipientSchema.pick({
id: true,
email: true,
name: true,
authOptions: true,
role: true,
}).array(),
});
export const ZSuccessfulGetTemplateResponseSchema = ZTemplateWithDataSchema;
export const ZSuccessfulDeleteTemplateResponseSchema = ZTemplateSchema;
export const ZSuccessfulGetTemplatesResponseSchema = z.object({
templates: ZTemplateWithDataSchema.omit({
templateDocumentData: true,
templateMeta: true,
}).array(),
totalPages: z.number(),
});
export const ZGetTemplatesQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(1),
});

View File

@ -292,7 +292,9 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', async ({ page }) => {
test('[DOCUMENT_FLOW]: should not be able to create a document without signatures', async ({
page,
}) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
@ -323,43 +325,9 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await page.getByRole('link', { name: documentTitle }).click();
await page.waitForURL(/\/documents\/\d+/);
// Start signing process
const url = page.url().split('/');
const documentId = url[url.length - 1];
const { token } = await getRecipientByEmail({
email: 'user1@example.com',
documentId: Number(documentId),
});
await page.goto(`/sign/${token}`);
await page.waitForURL(`/sign/${token}`);
// Check if document has been viewed
const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
await expect(
page.getByRole('dialog').getByText('No signature field found').first(),
).toBeVisible();
await unseedUser(user.id);
});
@ -449,6 +417,9 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
await page.getByPlaceholder('Email').fill('user1@example.com');
await page.getByPlaceholder('Name').fill('User 1');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to approve').getByText('Needs to approve').click();
await page.getByRole('button', { name: 'Continue' }).click();
// Add fields
@ -480,8 +451,8 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible();
await page.getByRole('button', { name: 'Approve' }).click();
await page.waitForURL('https://documenso.com');

View File

@ -6,5 +6,9 @@ import { getPricesByPlan } from './get-prices-by-plan';
* Returns the Stripe prices of items that affect the amount of documents a user can create.
*/
export const getDocumentRelatedPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
return await getPricesByPlan([
STRIPE_PLAN_TYPE.REGULAR,
STRIPE_PLAN_TYPE.COMMUNITY,
STRIPE_PLAN_TYPE.ENTERPRISE,
]);
};

View File

@ -1,5 +1,6 @@
import type Stripe from 'stripe';
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
// Utility type to handle usage of the `expand` option.
@ -11,7 +12,7 @@ export type GetPricesByIntervalOptions = {
/**
* Filter products by their meta 'plan' attribute.
*/
plan?: 'community';
plan?: STRIPE_PLAN_TYPE.COMMUNITY | STRIPE_PLAN_TYPE.REGULAR;
};
export const getPricesByInterval = async ({ plan }: GetPricesByIntervalOptions = {}) => {

View File

@ -6,5 +6,9 @@ import { getPricesByPlan } from './get-prices-by-plan';
* Returns the prices of items that count as the account's primary plan.
*/
export const getPrimaryAccountPlanPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
return await getPricesByPlan([
STRIPE_PLAN_TYPE.REGULAR,
STRIPE_PLAN_TYPE.COMMUNITY,
STRIPE_PLAN_TYPE.ENTERPRISE,
]);
};

View File

@ -1,3 +1,4 @@
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import config from '@documenso/tailwind-config';
import {
@ -14,9 +15,11 @@ import {
} from '../components';
import TemplateDocumentImage from '../template-components/template-document-image';
import { TemplateFooter } from '../template-components/template-footer';
import { RecipientRole } from '.prisma/client';
export type DocumentCompletedEmailTemplateProps = {
recipientName?: string;
recipientRole?: RecipientRole;
documentLink?: string;
documentName?: string;
assetBaseUrl?: string;
@ -24,11 +27,14 @@ export type DocumentCompletedEmailTemplateProps = {
export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
recipientName = 'John Doe',
recipientRole = RecipientRole.SIGNER,
documentLink = 'http://localhost:3000',
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
}: DocumentCompletedEmailTemplateProps) => {
const previewText = `Completed Document`;
const action = RECIPIENT_ROLES_DESCRIPTION[recipientRole].actioned.toLowerCase();
const previewText = `Document created from direct template`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
@ -61,7 +67,7 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
<Section>
<Text className="text-primary mb-0 text-center text-lg font-semibold">
{recipientName} signed a document by using one of your direct links
{recipientName} {action} a document by using one of your direct links
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-sm text-slate-600">

View File

@ -18,7 +18,7 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps)
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
downloadFile({
filename: `${baseTitle}.pdf`,
filename: `${baseTitle}_signed.pdf`,
data: blob,
});
};

View File

@ -4,6 +4,7 @@ export enum STRIPE_CUSTOMER_TYPE {
}
export enum STRIPE_PLAN_TYPE {
REGULAR = 'regular',
TEAM = 'team',
COMMUNITY = 'community',
ENTERPRISE = 'enterprise',

View File

@ -25,6 +25,7 @@ export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false,
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.
app_public_profile: true,
marketing_header_single_player_mode: false,
marketing_profiles_announcement_bar: true,
} as const;

View File

@ -0,0 +1,12 @@
import { JobClient } from './client/client';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/send-confirmation-email';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/send-signing-email';
/**
* The `as const` assertion is load bearing as it provides the correct level of type inference for
* triggering jobs.
*/
export const jobsClient = new JobClient([
SEND_SIGNING_EMAIL_JOB_DEFINITION,
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
] as const);

View File

@ -0,0 +1,61 @@
import { z } from 'zod';
import type { Json } from './json';
export type SimpleTriggerJobOptions = {
id?: string;
name: string;
payload: unknown;
timestamp?: number;
};
export const ZSimpleTriggerJobOptionsSchema = z.object({
id: z.string().optional(),
name: z.string(),
payload: z.unknown().refine((x) => x !== undefined, { message: 'payload is required' }),
timestamp: z.number().optional(),
});
// Map the array to create a union of objects we may accept
export type TriggerJobOptions<Definitions extends ReadonlyArray<JobDefinition> = []> = {
[K in keyof Definitions]: {
id?: string;
name: Definitions[K]['trigger']['name'];
payload: Definitions[K]['trigger']['schema'] extends z.ZodType<infer Shape> ? Shape : unknown;
timestamp?: number;
};
}[number];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type JobDefinition<Name extends string = string, Schema = any> = {
id: string;
name: string;
version: string;
enabled?: boolean;
trigger: {
name: Name;
schema?: z.ZodType<Schema>;
};
handler: (options: { payload: Schema; io: JobRunIO }) => Promise<Json | void>;
};
export interface JobRunIO {
// stableRun<T extends Json | void>(cacheKey: string, callback: (io: JobRunIO) => T | Promise<T>): Promise<T>;
runTask<T extends Json | void | undefined>(
cacheKey: string,
callback: () => Promise<T>,
): Promise<T>;
triggerJob(cacheKey: string, options: SimpleTriggerJobOptions): Promise<unknown>;
wait(cacheKey: string, ms: number): Promise<void>;
logger: {
info(...args: unknown[]): void;
error(...args: unknown[]): void;
debug(...args: unknown[]): void;
warn(...args: unknown[]): void;
log(...args: unknown[]): void;
};
}
export const defineJob = <N extends string, T = unknown>(
job: JobDefinition<N, T>,
): JobDefinition<N, T> => job;

View File

@ -0,0 +1,14 @@
/**
* Below type is borrowed from Trigger.dev's SDK, it may be moved elsewhere later.
*/
export type JsonPrimitive = string | number | boolean | null | undefined | Date | symbol;
export type JsonArray = Json[];
export type JsonRecord<T> = {
[Property in keyof T]: Json;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Json<T = any> = JsonPrimitive | JsonArray | JsonRecord<T>;

View File

@ -0,0 +1,19 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type { JobDefinition, SimpleTriggerJobOptions } from './_internal/job';
export abstract class BaseJobProvider {
// eslint-disable-next-line @typescript-eslint/require-await
public async triggerJob(_options: SimpleTriggerJobOptions): Promise<void> {
throw new Error('Not implemented');
}
// eslint-disable-next-line @typescript-eslint/require-await
public defineJob<N extends string, T>(_job: JobDefinition<N, T>): void {
throw new Error('Not implemented');
}
public getApiHandler(): (req: NextApiRequest, res: NextApiResponse) => Promise<Response | void> {
throw new Error('Not implemented');
}
}

View File

@ -0,0 +1,30 @@
import { match } from 'ts-pattern';
import type { JobDefinition, TriggerJobOptions } from './_internal/job';
import type { BaseJobProvider as JobClientProvider } from './base';
import { InngestJobProvider } from './inngest';
import { LocalJobProvider } from './local';
import { TriggerJobProvider } from './trigger';
export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
private _provider: JobClientProvider;
public constructor(definitions: T) {
this._provider = match(process.env.NEXT_PRIVATE_JOBS_PROVIDER)
.with('inngest', () => InngestJobProvider.getInstance())
.with('trigger', () => TriggerJobProvider.getInstance())
.otherwise(() => LocalJobProvider.getInstance());
definitions.forEach((definition) => {
this._provider.defineJob(definition);
});
}
public async triggerJob(options: TriggerJobOptions<T>) {
return this._provider.triggerJob(options);
}
public getApiHandler() {
return this._provider.getApiHandler();
}
}

View File

@ -0,0 +1,120 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type { NextRequest } from 'next/server';
import type { Context, Handler, InngestFunction } from 'inngest';
import { Inngest as InngestClient } from 'inngest';
import type { Logger } from 'inngest/middleware/logger';
import { serve as createPagesRoute } from 'inngest/next';
import { json } from 'micro';
import type { JobDefinition, JobRunIO, SimpleTriggerJobOptions } from './_internal/job';
import { BaseJobProvider } from './base';
export class InngestJobProvider extends BaseJobProvider {
private static _instance: InngestJobProvider;
private _client: InngestClient;
private _functions: Array<InngestFunction<InngestFunction.Options, Handler.Any, Handler.Any>> =
[];
private constructor(options: { client: InngestClient }) {
super();
this._client = options.client;
}
static getInstance() {
if (!this._instance) {
const client = new InngestClient({
id: 'documenso-app',
eventKey: process.env.NEXT_PRIVATE_INNGEST_EVENT_KEY,
});
this._instance = new InngestJobProvider({ client });
}
return this._instance;
}
public defineJob<N extends string, T>(job: JobDefinition<N, T>): void {
console.log('defining job', job.id);
const fn = this._client.createFunction(
{
id: job.id,
name: job.name,
},
{
event: job.trigger.name,
},
async (ctx) => {
const io = this.convertInngestIoToJobRunIo(ctx);
// We need to cast to any so we can deal with parsing later.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
let payload = ctx.event.data as any;
if (job.trigger.schema) {
payload = job.trigger.schema.parse(payload);
}
await job.handler({ payload, io });
},
);
this._functions.push(fn);
}
public async triggerJob(options: SimpleTriggerJobOptions): Promise<void> {
await this._client.send({
id: options.id,
name: options.name,
data: options.payload,
ts: options.timestamp,
});
}
public getApiHandler() {
const handler = createPagesRoute({
client: this._client,
functions: this._functions,
});
return async (req: NextApiRequest, res: NextApiResponse) => {
// Since body-parser is disabled for this route we need to patch in the parsed body
if (req.headers['content-type'] === 'application/json') {
Object.assign(req, {
body: await json(req),
});
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const nextReq = req as unknown as NextRequest;
return await handler(nextReq, res);
};
}
private convertInngestIoToJobRunIo(ctx: Context.Any & { logger: Logger }) {
const { step } = ctx;
return {
wait: step.sleep,
logger: {
...ctx.logger,
log: ctx.logger.info,
},
runTask: async (cacheKey, callback) => {
const result = await step.run(cacheKey, callback);
// !: Not dealing with this right now but it should be correct.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
return result as any;
},
triggerJob: async (cacheKey, payload) =>
step.sendEvent(cacheKey, {
...payload,
timestamp: payload.timestamp,
}),
} satisfies JobRunIO;
}
}

View File

@ -0,0 +1,351 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { sha256 } from '@noble/hashes/sha256';
import { json } from 'micro';
import { prisma } from '@documenso/prisma';
import { BackgroundJobStatus, Prisma } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { sign } from '../../server-only/crypto/sign';
import { verify } from '../../server-only/crypto/verify';
import {
type JobDefinition,
type JobRunIO,
type SimpleTriggerJobOptions,
ZSimpleTriggerJobOptionsSchema,
} from './_internal/job';
import type { Json } from './_internal/json';
import { BaseJobProvider } from './base';
export class LocalJobProvider extends BaseJobProvider {
private static _instance: LocalJobProvider;
private _jobDefinitions: Record<string, JobDefinition> = {};
private constructor() {
super();
}
static getInstance() {
if (!this._instance) {
this._instance = new LocalJobProvider();
}
return this._instance;
}
public defineJob<N extends string, T>(definition: JobDefinition<N, T>) {
this._jobDefinitions[definition.id] = {
...definition,
enabled: definition.enabled ?? true,
};
}
public async triggerJob(options: SimpleTriggerJobOptions) {
console.log({ jobDefinitions: this._jobDefinitions });
const eligibleJobs = Object.values(this._jobDefinitions).filter(
(job) => job.trigger.name === options.name,
);
console.log({ options });
console.log(
'Eligible jobs:',
eligibleJobs.map((job) => job.name),
);
await Promise.all(
eligibleJobs.map(async (job) => {
// Ideally we will change this to a createMany with returning later once we upgrade Prisma
// @see: https://github.com/prisma/prisma/releases/tag/5.14.0
const pendingJob = await prisma.backgroundJob.create({
data: {
jobId: job.id,
name: job.name,
version: job.version,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
payload: options.payload as Prisma.InputJsonValue,
},
});
await this.submitJobToEndpoint({
jobId: pendingJob.id,
jobDefinitionId: pendingJob.jobId,
data: options,
});
}),
);
}
public getApiHandler() {
return async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== 'POST') {
res.status(405).send('Method not allowed');
}
const jobId = req.headers['x-job-id'];
const signature = req.headers['x-job-signature'];
const isRetry = req.headers['x-job-retry'] !== undefined;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const options = await json(req)
.then(async (data) => ZSimpleTriggerJobOptionsSchema.parseAsync(data))
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
.then((data) => data as SimpleTriggerJobOptions)
.catch(() => null);
if (!options) {
res.status(400).send('Bad request');
return;
}
const definition = this._jobDefinitions[options.name];
if (
typeof jobId !== 'string' ||
typeof signature !== 'string' ||
typeof options !== 'object'
) {
res.status(400).send('Bad request');
return;
}
if (!definition) {
res.status(404).send('Job not found');
return;
}
if (definition && !definition.enabled) {
console.log('Attempted to trigger a disabled job', options.name);
res.status(404).send('Job not found');
return;
}
if (!signature || !verify(options, signature)) {
res.status(401).send('Unauthorized');
return;
}
if (definition.trigger.schema) {
const result = definition.trigger.schema.safeParse(options.payload);
if (!result.success) {
res.status(400).send('Bad request');
return;
}
}
console.log(`[JOBS]: Triggering job ${options.name} with payload`, options.payload);
let backgroundJob = await prisma.backgroundJob
.update({
where: {
id: jobId,
status: BackgroundJobStatus.PENDING,
},
data: {
status: BackgroundJobStatus.PROCESSING,
retried: {
increment: isRetry ? 1 : 0,
},
lastRetriedAt: isRetry ? new Date() : undefined,
},
})
.catch(() => null);
if (!backgroundJob) {
res.status(404).send('Job not found');
return;
}
try {
await definition.handler({
payload: options.payload,
io: this.createJobRunIO(jobId),
});
backgroundJob = await prisma.backgroundJob.update({
where: {
id: jobId,
status: BackgroundJobStatus.PROCESSING,
},
data: {
status: BackgroundJobStatus.COMPLETED,
completedAt: new Date(),
},
});
} catch (error) {
console.error(`[JOBS]: Job ${options.name} failed`, error);
const taskHasExceededRetries = error instanceof BackgroundTaskExceededRetriesError;
const jobHasExceededRetries =
backgroundJob.retried >= backgroundJob.maxRetries &&
!(error instanceof BackgroundTaskFailedError);
if (taskHasExceededRetries || jobHasExceededRetries) {
backgroundJob = await prisma.backgroundJob.update({
where: {
id: jobId,
status: BackgroundJobStatus.PROCESSING,
},
data: {
status: BackgroundJobStatus.FAILED,
completedAt: new Date(),
},
});
res.status(500).send('Task exceeded retries');
return;
}
backgroundJob = await prisma.backgroundJob.update({
where: {
id: jobId,
status: BackgroundJobStatus.PROCESSING,
},
data: {
status: BackgroundJobStatus.PENDING,
},
});
await this.submitJobToEndpoint({
jobId,
jobDefinitionId: backgroundJob.jobId,
data: options,
});
}
res.status(200).send('OK');
};
}
private async submitJobToEndpoint(options: {
jobId: string;
jobDefinitionId: string;
data: SimpleTriggerJobOptions;
isRetry?: boolean;
}) {
const { jobId, jobDefinitionId, data, isRetry } = options;
const endpoint = `${NEXT_PUBLIC_WEBAPP_URL()}/api/jobs/${jobDefinitionId}/${jobId}`;
const signature = sign(data);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Job-Id': jobId,
'X-Job-Signature': signature,
};
if (isRetry) {
headers['X-Job-Retry'] = '1';
}
console.log('Submitting job to endpoint:', endpoint);
await Promise.race([
fetch(endpoint, {
method: 'POST',
body: JSON.stringify(data),
headers,
}).catch(() => null),
new Promise((resolve) => {
setTimeout(resolve, 150);
}),
]);
}
private createJobRunIO(jobId: string): JobRunIO {
return {
runTask: async <T extends void | Json>(cacheKey: string, callback: () => Promise<T>) => {
const hashedKey = Buffer.from(sha256(cacheKey)).toString('hex');
let task = await prisma.backgroundJobTask.findFirst({
where: {
id: `task-${hashedKey}--${jobId}`,
jobId,
},
});
if (!task) {
task = await prisma.backgroundJobTask.create({
data: {
id: `task-${hashedKey}--${jobId}`,
name: cacheKey,
jobId,
status: BackgroundJobStatus.PENDING,
},
});
}
if (task.status === BackgroundJobStatus.COMPLETED) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return task.result as T;
}
if (task.retried >= 3) {
throw new BackgroundTaskExceededRetriesError('Task exceeded retries');
}
try {
const result = await callback();
task = await prisma.backgroundJobTask.update({
where: {
id: task.id,
jobId,
},
data: {
status: BackgroundJobStatus.COMPLETED,
result: result === null ? Prisma.JsonNull : result,
completedAt: new Date(),
},
});
return result;
} catch {
task = await prisma.backgroundJobTask.update({
where: {
id: task.id,
jobId,
},
data: {
status: BackgroundJobStatus.PENDING,
retried: {
increment: 1,
},
},
});
throw new BackgroundTaskFailedError('Task failed');
}
},
triggerJob: async (_cacheKey, payload) => await this.triggerJob(payload),
logger: {
debug: (...args) => console.debug(`[${jobId}]`, ...args),
error: (...args) => console.error(`[${jobId}]`, ...args),
info: (...args) => console.info(`[${jobId}]`, ...args),
log: (...args) => console.log(`[${jobId}]`, ...args),
warn: (...args) => console.warn(`[${jobId}]`, ...args),
},
// eslint-disable-next-line @typescript-eslint/require-await
wait: async () => {
throw new Error('Not implemented');
},
};
}
}
class BackgroundTaskFailedError extends Error {
constructor(message: string) {
super(message);
this.name = 'BackgroundTaskFailedError';
}
}
class BackgroundTaskExceededRetriesError extends Error {
constructor(message: string) {
super(message);
this.name = 'BackgroundTaskExceededRetriesError';
}
}

View File

@ -0,0 +1,73 @@
import { createPagesRoute } from '@trigger.dev/nextjs';
import type { IO } from '@trigger.dev/sdk';
import { TriggerClient, eventTrigger } from '@trigger.dev/sdk';
import type { JobDefinition, JobRunIO, SimpleTriggerJobOptions } from './_internal/job';
import { BaseJobProvider } from './base';
export class TriggerJobProvider extends BaseJobProvider {
private static _instance: TriggerJobProvider;
private _client: TriggerClient;
private constructor(options: { client: TriggerClient }) {
super();
this._client = options.client;
}
static getInstance() {
if (!this._instance) {
const client = new TriggerClient({
id: 'documenso-app',
apiKey: process.env.NEXT_PRIVATE_TRIGGER_API_KEY,
apiUrl: process.env.NEXT_PRIVATE_TRIGGER_API_URL,
});
this._instance = new TriggerJobProvider({ client });
}
return this._instance;
}
public defineJob<N extends string, T>(job: JobDefinition<N, T>): void {
this._client.defineJob({
id: job.id,
name: job.name,
version: job.version,
trigger: eventTrigger({
name: job.trigger.name,
schema: job.trigger.schema,
}),
run: async (payload, io) => job.handler({ payload, io: this.convertTriggerIoToJobRunIo(io) }),
});
}
public async triggerJob(options: SimpleTriggerJobOptions): Promise<void> {
await this._client.sendEvent({
id: options.id,
name: options.name,
payload: options.payload,
timestamp: options.timestamp ? new Date(options.timestamp) : undefined,
});
}
public getApiHandler() {
const { handler } = createPagesRoute(this._client);
return handler;
}
private convertTriggerIoToJobRunIo(io: IO) {
return {
wait: io.wait,
logger: io.logger,
runTask: async (cacheKey, callback) => io.runTask(cacheKey, callback),
triggerJob: async (cacheKey, payload) =>
io.sendEvent(cacheKey, {
...payload,
timestamp: payload.timestamp ? new Date(payload.timestamp) : undefined,
}),
} satisfies JobRunIO;
}
}

View File

@ -0,0 +1,30 @@
import { z } from 'zod';
import { sendConfirmationToken } from '../../server-only/user/send-confirmation-token';
import type { JobDefinition } from '../client/_internal/job';
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID = 'send.signup.confirmation.email';
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
email: z.string().email(),
force: z.boolean().optional(),
});
export const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION = {
id: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
name: 'Send Confirmation Email',
version: '1.0.0',
trigger: {
name: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
schema: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload }) => {
await sendConfirmationToken({
email: payload.email,
force: payload.force,
});
},
} as const satisfies JobDefinition<
typeof SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
z.infer<typeof SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA>
>;

View File

@ -0,0 +1,171 @@
import { createElement } from 'react';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
import { prisma } from '@documenso/prisma';
import {
DocumentSource,
DocumentStatus,
RecipientRole,
SendStatus,
} from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRequestMetadataSchema } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
import { type JobDefinition } from '../client/_internal/job';
const SEND_SIGNING_EMAIL_JOB_DEFINITION_ID = 'send.signing.requested.email';
const SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
userId: z.number(),
documentId: z.number(),
recipientId: z.number(),
requestMetadata: ZRequestMetadataSchema.optional(),
});
export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
id: SEND_SIGNING_EMAIL_JOB_DEFINITION_ID,
name: 'Send Signing Email',
version: '1.0.0',
trigger: {
name: SEND_SIGNING_EMAIL_JOB_DEFINITION_ID,
schema: SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const { userId, documentId, recipientId, requestMetadata } = payload;
const [user, document, recipient] = await Promise.all([
prisma.user.findFirstOrThrow({
where: {
id: userId,
},
}),
prisma.document.findFirstOrThrow({
where: {
id: documentId,
status: DocumentStatus.PENDING,
},
include: {
documentMeta: true,
},
}),
prisma.recipient.findFirstOrThrow({
where: {
id: recipientId,
},
}),
]);
const { documentMeta } = document;
if (recipient.role === RecipientRole.CC) {
return;
}
const customEmail = document?.documentMeta;
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
const recipientActionVerb = actionVerb.toLowerCase();
let emailMessage = customEmail?.message || '';
let emailSubject = `Please ${recipientActionVerb} this document`;
if (selfSigner) {
emailMessage = `You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`;
emailSubject = `Please ${recipientActionVerb} your document`;
}
if (isDirectTemplate) {
emailMessage = `A document was created by your direct template that requires you to ${recipientActionVerb} it.`;
emailSubject = `Please ${recipientActionVerb} this document created by your direct template`;
}
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': document.title,
};
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
role: recipient.role,
selfSigner,
});
await io.runTask('send-signing-email', async () => {
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: renderCustomEmailTemplate(
documentMeta?.subject || emailSubject,
customEmailTemplate,
),
html: render(template),
text: render(template, { plainText: true }),
});
});
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
});
await io.runTask('store-audit-log', async () => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
recipientRole: recipient.role,
isResending: false,
},
}),
});
});
},
} as const satisfies JobDefinition<
typeof SEND_SIGNING_EMAIL_JOB_DEFINITION_ID,
z.infer<typeof SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA>
>;

View File

@ -14,11 +14,11 @@ import { prisma } from '@documenso/prisma';
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../errors/app-error';
import { jobsClient } from '../jobs/client';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
import { getUserByEmail } from '../server-only/user/get-user-by-email';
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
import type { TAuthenticationResponseJSONSchema } from '../types/webauthn';
import { ZAuthenticationResponseJSONSchema } from '../types/webauthn';
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
@ -108,7 +108,12 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
mostRecentToken.expires.valueOf() <= Date.now() ||
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
await sendConfirmationToken({ email });
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
email: user.email,
},
});
}
throw new Error(ErrorCode.UNVERIFIED_EMAIL);

View File

@ -32,10 +32,14 @@
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@trigger.dev/nextjs": "^2.3.18",
"@trigger.dev/sdk": "^2.3.18",
"@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0",
"inngest": "^3.19.13",
"kysely": "^0.26.3",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"nanoid": "^4.0.2",
"next": "14.0.3",
"next-auth": "4.24.5",
@ -45,13 +49,14 @@
"playwright": "1.43.0",
"react": "18.2.0",
"remeda": "^1.27.1",
"sharp": "0.32.6",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",
"zod": "^3.22.4"
},
"devDependencies": {
"@playwright/browser-chromium": "1.43.0",
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4",
"@playwright/browser-chromium": "1.43.0"
"@types/pg": "^8.11.4"
}
}

View File

@ -1,5 +1,7 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
export const getUsersCount = async () => {
return await prisma.user.count();
@ -16,3 +18,65 @@ export const getUsersWithSubscriptionsCount = async () => {
},
});
};
export const getUserWithAtLeastOneDocumentPerMonth = async () => {
return await prisma.user.count({
where: {
Document: {
some: {
createdAt: {
gte: DateTime.now().minus({ months: 1 }).toJSDate(),
},
},
},
},
});
};
export const getUserWithAtLeastOneDocumentSignedPerMonth = async () => {
return await prisma.user.count({
where: {
Document: {
some: {
status: {
equals: DocumentStatus.COMPLETED,
},
completedAt: {
gte: DateTime.now().minus({ months: 1 }).toJSDate(),
},
},
},
},
});
};
export type GetUserWithDocumentMonthlyGrowth = Array<{
month: string;
count: number;
signed_count: number;
}>;
type GetUserWithDocumentMonthlyGrowthQueryResult = Array<{
month: Date;
count: bigint;
signed_count: bigint;
}>;
export const getUserWithSignedDocumentMonthlyGrowth = async () => {
const result = await prisma.$queryRaw<GetUserWithDocumentMonthlyGrowthQueryResult>`
SELECT
DATE_TRUNC('month', "Document"."createdAt") AS "month",
COUNT(DISTINCT "Document"."userId") as "count",
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."userId" END) as "signed_count"
FROM "Document"
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
signed_count: Number(row.signed_count),
}));
};

View File

@ -1,31 +1,14 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import {
DocumentSource,
DocumentStatus,
RecipientRole,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../constants/recipient-roles';
import { jobsClient } from '../../jobs/client';
import { getFile } from '../../universal/upload/get-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -82,8 +65,6 @@ export const sendDocument = async ({
},
});
const customEmail = document?.documentMeta;
if (!document) {
throw new Error('Document not found');
}
@ -98,8 +79,6 @@ export const sendDocument = async ({
const { documentData } = document;
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
if (!documentData.data) {
throw new Error('Document data not found');
}
@ -109,6 +88,7 @@ export const sendDocument = async ({
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(file),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
formValues: document.formValues as Record<string, string | number | boolean>,
});
@ -130,6 +110,31 @@ export const sendDocument = async ({
Object.assign(document, result);
}
// Commented out server side checks for minimum 1 signature per signer now since we need to
// decide if we want to enforce this for API & templates.
// const fields = await getFieldsForDocument({
// documentId: documentId,
// userId: userId,
// });
// const fieldsWithSignerEmail = fields.map((field) => ({
// ...field,
// signerEmail:
// document.Recipient.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
// }));
// const everySignerHasSignature = document?.Recipient.every(
// (recipient) =>
// recipient.role !== RecipientRole.SIGNER ||
// fieldsWithSignerEmail.some(
// (field) => field.type === 'SIGNATURE' && field.signerEmail === recipient.email,
// ),
// );
// if (!everySignerHasSignature) {
// throw new Error('Some signers have not been assigned a signature field.');
// }
if (sendEmail) {
await Promise.all(
document.Recipient.map(async (recipient) => {
@ -137,92 +142,15 @@ export const sendDocument = async ({
return;
}
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
const recipientActionVerb = actionVerb.toLowerCase();
let emailMessage = customEmail?.message || '';
let emailSubject = `Please ${recipientActionVerb} this document`;
if (selfSigner) {
emailMessage = `You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`;
emailSubject = `Please ${recipientActionVerb} your document`;
}
if (isDirectTemplate) {
emailMessage = `A document was created by your direct template that requires you to ${recipientActionVerb} it.`;
emailSubject = `Please ${recipientActionVerb} this document created by your direct template`;
}
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': document.title,
};
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
role: recipient.role,
selfSigner,
});
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: emailSubject,
html: render(template),
text: render(template, { plainText: true }),
});
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: false,
},
}),
});
await jobsClient.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId,
documentId,
recipientId: recipient.id,
requestMetadata,
},
{ timeout: 30_000 },
);
});
}),
);
}

View File

@ -26,6 +26,7 @@ export const getCompletedFieldsForToken = async ({ token }: GetCompletedFieldsFo
select: {
name: true,
email: true,
signingStatus: true,
},
},
},

View File

@ -5,6 +5,8 @@ export interface GetFieldsForDocumentOptions {
userId: number;
}
export type DocumentField = Awaited<ReturnType<typeof getFieldsForDocument>>[number];
export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForDocumentOptions) => {
const fields = await prisma.field.findMany({
where: {
@ -26,6 +28,16 @@ export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForD
],
},
},
include: {
Signature: true,
Recipient: {
select: {
name: true,
email: true,
signingStatus: true,
},
},
},
orderBy: {
id: 'asc',
},

View File

@ -1,6 +1,7 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { PDFDocument } from 'pdf-lib';
import { PDFDocument, RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
import { match } from 'ts-pattern';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
@ -37,7 +38,32 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
throw new Error(`Page ${field.page} does not exist`);
}
const { width: pageWidth, height: pageHeight } = page.getSize();
const pageRotation = page.getRotation();
let pageRotationInDegrees = match(pageRotation.type)
.with(RotationTypes.Degrees, () => pageRotation.angle)
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
.exhaustive();
// Round to the closest multiple of 90 degrees.
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
//
// To account for this, we swap the width and height for pages that are rotated by 90/270
// degrees. This is so we can calculate the virtual position the field was placed if it
// was correctly oriented in the frontend.
//
// Then when we insert the fields, we apply a transformation to the position of the field
// so it is rotated correctly.
if (isPageRotatedToLandscape) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
}
const fieldWidth = pageWidth * (Number(field.width) / 100);
const fieldHeight = pageHeight * (Number(field.height) / 100);
@ -65,17 +91,31 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
const imageX = fieldX + (fieldWidth - imageWidth) / 2;
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
imageY = pageHeight - imageY - imageHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
imageX,
imageY,
pageRotationInDegrees,
);
imageX = adjustedPosition.xPos;
imageY = adjustedPosition.yPos;
}
page.drawImage(image, {
x: imageX,
y: imageY,
width: imageWidth,
height: imageHeight,
rotate: degrees(pageRotationInDegrees),
});
} else {
const longestLineInTextForWidth = field.customText
@ -90,17 +130,31 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textX = fieldX + (fieldWidth - textWidth) / 2;
let textX = fieldX + (fieldWidth - textWidth) / 2;
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
}
page.drawText(field.customText, {
x: textX,
y: textY,
size: fontSize,
font,
rotate: degrees(pageRotationInDegrees),
});
}
@ -117,3 +171,32 @@ export const insertFieldInPDFBytes = async (
return await pdfDoc.save();
};
const adjustPositionForRotation = (
pageWidth: number,
pageHeight: number,
xPos: number,
yPos: number,
pageRotationInDegrees: number,
) => {
if (pageRotationInDegrees === 270) {
xPos = pageWidth - xPos;
[xPos, yPos] = [yPos, xPos];
}
if (pageRotationInDegrees === 90) {
yPos = pageHeight - yPos;
[xPos, yPos] = [yPos, xPos];
}
// Invert all the positions since it's rotated by 180 degrees.
if (pageRotationInDegrees === 180) {
xPos = pageWidth - xPos;
yPos = pageHeight - yPos;
}
return {
xPos,
yPos,
};
};

View File

@ -0,0 +1,26 @@
import sharp from 'sharp';
import { prisma } from '@documenso/prisma';
export type GetAvatarImageOptions = {
id: string;
};
export const getAvatarImage = async ({ id }: GetAvatarImageOptions) => {
const avatarImage = await prisma.avatarImage.findFirst({
where: {
id,
},
});
if (!avatarImage) {
return null;
}
const bytes = Buffer.from(avatarImage.bytes, 'base64');
return {
contentType: 'image/jpeg',
content: await sharp(bytes).toFormat('jpeg').toBuffer(),
};
};

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