mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Merge branch 'main' into chore/freelance-blog-2
This commit is contained in:
62
apps/marketing/content/blog/announcing-direct-links.mdx
Normal file
62
apps/marketing/content/blog/announcing-direct-links.mdx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
title: Launching Direct Links
|
||||||
|
description: Today, we are launching direct links to templates, a new and async way to get documents signed.
|
||||||
|
authorName: 'Timur Ercan'
|
||||||
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2024-06-17
|
||||||
|
tags:
|
||||||
|
- Announcement
|
||||||
|
- Direct Links
|
||||||
|
- Profiles
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/direct-links.png"
|
||||||
|
width="1400"
|
||||||
|
height="884"
|
||||||
|
alt="Direct Links in Templats List View"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">Direct Template Links - Async signing, anytime.</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
> 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:
|
||||||
|
|
||||||
|
- I have to become active each time before anything can happen: I need to send a signature request
|
||||||
|
- My counterpart has to wait for me to send: "Did you send the signing link yet?"
|
||||||
|
- 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
|
||||||
|
id="vid"
|
||||||
|
width="100%"
|
||||||
|
src="https://github.com/documenso/design/assets/1309312/129f690b-29b4-4a11-b9a0-14fc6648e611"
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
Best from Hamburg\
|
||||||
|
Timur
|
||||||
@ -1,43 +1,39 @@
|
|||||||
---
|
---
|
||||||
title: How Documenso Helps Freelancers Close More Clients Efficiently
|
title: How Documenso Helps Freelancers Close More Clients Efficiently
|
||||||
description: Reducing friction when sending proposals is critical to close new clients. By using Documenso, freelancers can save time, enhance client interactions, and ultimately close more deals 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'
|
authorName: 'Timur Ercan'
|
||||||
authorImage: '/blog/blog-author-timur.jpeg'
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
authorRole: 'Co-Founder'
|
authorRole: 'Co-Founder'
|
||||||
date: 2024-06-20
|
date: 2024-06-14
|
||||||
tags:
|
tags:
|
||||||
- Freelancer
|
- Freelancer
|
||||||
- Proposal
|
- Proposal
|
||||||
- Productivity
|
- 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.
|
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
|
## Understanding Proposal and Contracts
|
||||||
|
|
||||||
### 1. Initial Proposal
|
### 1. Initial Proposal
|
||||||
|
|
||||||
> Agreeing on what needs to be done and terms for payment
|
> 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. A customer usually decides if the offer is what they want based on the proposal.
|
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
|
### 2. Formal Contract
|
||||||
|
|
||||||
> After the client signs the proposal, a contract can be signed, formalizing the agreement and adding detailed legal terms.
|
> 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.
|
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.
|
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**
|
### **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 can’t be changed after the fact, and every step of the process is logged.
|
||||||
Signing documents digitally makes closing proposals and contracts faster and more secure. Each party can review and sign the documents in question in minutes instead of days (inserting the signatures manually 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 can’t 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).
|
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
|
## 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 doesn’t 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:
|
||||||
If you already have a proposal template, create a new version for your client and export it to PDF. If your tool doesn’t 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
|
- A clear and concise title
|
||||||
- Your contact information
|
- Your contact information
|
||||||
@ -46,13 +42,13 @@ If you already have a proposal template, create a new version for your client an
|
|||||||
- A summary of the project
|
- A summary of the project
|
||||||
- A detailed description of the project
|
- A detailed description of the project
|
||||||
- Goals, outcomes, and deliverables
|
- Goals, outcomes, and deliverables
|
||||||
- Tasks and activities to achieve the goals and Outcomes
|
- Tasks and activities to achieve the goals and outcomes
|
||||||
- Timeline with milestones and deadlines
|
- A timeline with milestones and deadlines
|
||||||
- Pricing terms and payment schedule
|
- Pricing terms and payment schedule
|
||||||
- Summary of major terms for the coming contract
|
- Summary of major terms for the coming contract
|
||||||
|
|
||||||
## Sending the Proposal
|
## Sending the Proposal
|
||||||
If you don’t have a Documenso Account yet, you can [create one for free](https://documen.so/signup?utm_source=blog-freelancer-proposal). Once you signed up you can upload your proposal PDF by simply dragging it onto the upload-area. Add your potential client as a recipient, add siganture field, an your are done! You can track the status if your proposal simply by clicking the Document in the overview. Documenso will also notify you, once the proposal is signed.
|
If you don’t 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
|
<video
|
||||||
id="vid"
|
id="vid"
|
||||||
@ -72,3 +68,4 @@ Let us know what you think and what we can improve. Which field types are you mi
|
|||||||
|
|
||||||
Best from Hamburg\
|
Best from Hamburg\
|
||||||
Timur
|
Timur
|
||||||
|
|
||||||
|
|||||||
49
apps/marketing/content/blog/sunsetting-early-adopters.mdx
Normal file
49
apps/marketing/content/blog/sunsetting-early-adopters.mdx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
45
apps/marketing/content/changelog.mdx
Normal file
45
apps/marketing/content/changelog.mdx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: Changelog - Documenso
|
||||||
|
description: Thoughts and details on the latest Documenso releases.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
´
|
||||||
@ -21,6 +21,9 @@
|
|||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
"@openstatus/react": "^0.0.3",
|
"@openstatus/react": "^0.0.3",
|
||||||
"contentlayer": "^0.3.4",
|
"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",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
|
|||||||
BIN
apps/marketing/public/blog/direct-links.png
Normal file
BIN
apps/marketing/public/blog/direct-links.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 239 KiB |
BIN
apps/marketing/public/blog/sunset.jpg
Normal file
BIN
apps/marketing/public/blog/sunset.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
BIN
apps/marketing/public/signing.mp4
Normal file
BIN
apps/marketing/public/signing.mp4
Normal file
Binary file not shown.
@ -47,14 +47,6 @@ export const TEAM_MEMBERS = [
|
|||||||
engagement: 'Full-Time',
|
engagement: 'Full-Time',
|
||||||
joinDate: 'October 9th, 2023',
|
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 = [
|
export const FUNDING_RAISED = [
|
||||||
|
|||||||
@ -247,8 +247,8 @@ export default async function OpenPage() {
|
|||||||
<BarMetric<EarlyAdoptersType>
|
<BarMetric<EarlyAdoptersType>
|
||||||
data={EARLY_ADOPTERS_DATA}
|
data={EARLY_ADOPTERS_DATA}
|
||||||
metricKey="earlyAdopters"
|
metricKey="earlyAdopters"
|
||||||
title="Early Adopters"
|
title="Total Customers"
|
||||||
label="Early Adopters"
|
label="Total Customers"
|
||||||
className="col-span-12 lg:col-span-6"
|
className="col-span-12 lg:col-span-6"
|
||||||
extraInfo={<OpenPageTooltip />}
|
extraInfo={<OpenPageTooltip />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export function OpenPageTooltip() {
|
|||||||
</svg>
|
</svg>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Active Subscriptions.</p>
|
<p>Customers with an Active Subscriptions.</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/accordion';
|
} from '@documenso/ui/primitives/accordion';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { Enterprise } from '~/components/(marketing)/enterprise';
|
||||||
import { PricingTable } from '~/components/(marketing)/pricing-table';
|
import { PricingTable } from '~/components/(marketing)/pricing-table';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -42,6 +43,10 @@ export default function PricingPage() {
|
|||||||
<PricingTable />
|
<PricingTable />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12">
|
||||||
|
<Enterprise />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto mt-36 max-w-2xl">
|
<div className="mx-auto mt-36 max-w-2xl">
|
||||||
<h2 className="text-center text-2xl font-semibold">
|
<h2 className="text-center text-2xl font-semibold">
|
||||||
None of these work for you? Try self-hosting!
|
None of these work for you? Try self-hosting!
|
||||||
|
|||||||
@ -34,17 +34,18 @@ export const Callout = ({ starCount }: CalloutProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||||
<Button
|
<Link href="https://app.documenso.com/signup?utm_source=marketing-callout">
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
variant="outline"
|
||||||
onClick={onSignUpClick}
|
className="rounded-full bg-transparent backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
Claim Early Adopter Plan
|
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">
|
<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
|
No Credit Card required
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="https://github.com/documenso/documenso"
|
href="https://github.com/documenso/documenso"
|
||||||
|
|||||||
261
apps/marketing/src/components/(marketing)/carousel.tsx
Normal file
261
apps/marketing/src/components/(marketing)/carousel.tsx
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import Autoplay from 'embla-carousel-autoplay';
|
||||||
|
import useEmblaCarousel from 'embla-carousel-react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
|
import { Card } from '@documenso/ui/primitives/card';
|
||||||
|
import { Progress } from '@documenso/ui/primitives/progress';
|
||||||
|
|
||||||
|
import { Slide } from './slide';
|
||||||
|
|
||||||
|
const SLIDES = [
|
||||||
|
{
|
||||||
|
label: 'Signing Process',
|
||||||
|
type: 'video',
|
||||||
|
srcLight: 'https://github.com/documenso/design/raw/main/marketing/signing.webm',
|
||||||
|
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/signing.webm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Teams',
|
||||||
|
type: 'video',
|
||||||
|
srcLight: 'https://github.com/documenso/design/raw/main/marketing/teams.webm',
|
||||||
|
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/teams.webm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Zapier',
|
||||||
|
type: 'video',
|
||||||
|
srcLight: 'https://github.com/documenso/design/raw/main/marketing/zapier.webm',
|
||||||
|
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/zapier.webm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Webhooks',
|
||||||
|
type: 'video',
|
||||||
|
srcLight: 'https://github.com/documenso/design/raw/main/marketing/webhooks.webm',
|
||||||
|
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/webhooks.webm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'API',
|
||||||
|
type: 'video',
|
||||||
|
srcLight: 'https://github.com/documenso/design/raw/main/marketing/api.webm',
|
||||||
|
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/api.webm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Profile',
|
||||||
|
type: 'video',
|
||||||
|
srcLight: 'https://github.com/documenso/design/raw/main/marketing/profile_teaser.webm',
|
||||||
|
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/profile_teaser.webm',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Carousel = () => {
|
||||||
|
const slides = SLIDES;
|
||||||
|
const [_isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
|
||||||
|
const [autoplayDelay, setAutoplayDelay] = useState<number[]>([]);
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [
|
||||||
|
Autoplay({ playOnInit: true, delay: autoplayDelay[selectedIndex] || 5000 }),
|
||||||
|
]);
|
||||||
|
const [emblaThumbsRef, emblaThumbsApi] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
loop: true,
|
||||||
|
containScroll: 'keepSnaps',
|
||||||
|
dragFree: true,
|
||||||
|
},
|
||||||
|
[Autoplay({ playOnInit: true, delay: autoplayDelay[selectedIndex] || 5000 })],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onThumbClick = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (!emblaApi || !emblaThumbsApi) return;
|
||||||
|
emblaApi.scrollTo(index);
|
||||||
|
},
|
||||||
|
[emblaApi, emblaThumbsApi],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSelect = useCallback(() => {
|
||||||
|
if (!emblaApi || !emblaThumbsApi) return;
|
||||||
|
setSelectedIndex(emblaApi.selectedScrollSnap());
|
||||||
|
emblaThumbsApi.scrollTo(emblaApi.selectedScrollSnap());
|
||||||
|
|
||||||
|
resetProgress();
|
||||||
|
const autoplay = emblaApi.plugins()?.autoplay;
|
||||||
|
|
||||||
|
if (autoplay) {
|
||||||
|
autoplay.reset();
|
||||||
|
}
|
||||||
|
}, [emblaApi, emblaThumbsApi, setSelectedIndex]);
|
||||||
|
|
||||||
|
const resetProgress = useCallback(() => {
|
||||||
|
setProgress(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const setVideoDurations = async () => {
|
||||||
|
const durations = await Promise.all(
|
||||||
|
videoRefs.current.map(
|
||||||
|
async (video) =>
|
||||||
|
new Promise<number>((resolve) => {
|
||||||
|
if (video) {
|
||||||
|
video.onloadedmetadata = () => resolve(video.duration * 1000);
|
||||||
|
} else {
|
||||||
|
resolve(5000);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
setAutoplayDelay(durations);
|
||||||
|
};
|
||||||
|
|
||||||
|
void setVideoDurations();
|
||||||
|
}, [slides, mounted, resolvedTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const video = entry.target as HTMLVideoElement;
|
||||||
|
video
|
||||||
|
.play()
|
||||||
|
.catch((error) => console.log('Error attempting to play the video:', error));
|
||||||
|
} else {
|
||||||
|
const video = entry.target as HTMLVideoElement;
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
threshold: 0.5,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
videoRefs.current.forEach((video) => {
|
||||||
|
if (video) {
|
||||||
|
observer.observe(video);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [slides, mounted, resolvedTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!emblaApi) return;
|
||||||
|
onSelect();
|
||||||
|
|
||||||
|
emblaApi.on('select', onSelect).on('reInit', onSelect);
|
||||||
|
}, [emblaApi, onSelect, mounted, resolvedTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const autoplay = emblaApi?.plugins()?.autoplay;
|
||||||
|
if (!autoplay) return;
|
||||||
|
|
||||||
|
setIsPlaying(autoplay.isPlaying());
|
||||||
|
emblaApi
|
||||||
|
.on('autoplay:play', () => setIsPlaying(true))
|
||||||
|
.on('autoplay:stop', () => setIsPlaying(false))
|
||||||
|
.on('reInit', () => setIsPlaying(autoplay.isPlaying()));
|
||||||
|
}, [emblaApi, mounted, resolvedTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoplayDelay[selectedIndex] === undefined) return;
|
||||||
|
|
||||||
|
const updateInterval = 50;
|
||||||
|
const increment = 100 / (autoplayDelay[selectedIndex] / updateInterval);
|
||||||
|
let progressValue = 0;
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setProgress((prevProgress) => {
|
||||||
|
progressValue = prevProgress + increment;
|
||||||
|
if (progressValue >= 100) {
|
||||||
|
clearInterval(timer);
|
||||||
|
if (emblaApi) {
|
||||||
|
emblaApi.scrollNext();
|
||||||
|
}
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
return progressValue;
|
||||||
|
});
|
||||||
|
}, updateInterval);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [selectedIndex, autoplayDelay, emblaApi, mounted, resolvedTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!emblaApi) return;
|
||||||
|
|
||||||
|
const resetCarousel = () => {
|
||||||
|
emblaApi.reInit();
|
||||||
|
emblaApi.scrollTo(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
resetCarousel();
|
||||||
|
}, [emblaApi, autoplayDelay, mounted, resolvedTheme]);
|
||||||
|
|
||||||
|
// Ensure the component renders only after mounting to avoid theme issues
|
||||||
|
if (!mounted) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="mx-auto mt-12 w-full max-w-4xl rounded-2xl p-1 before:rounded-2xl" gradient>
|
||||||
|
<div className="overflow-hidden rounded-xl" ref={emblaRef}>
|
||||||
|
<div className="flex touch-pan-y rounded-xl">
|
||||||
|
{slides.map((slide, index) => (
|
||||||
|
<div className="min-w-[10rem] flex-none basis-full rounded-xl" key={index}>
|
||||||
|
{slide.type === 'video' && (
|
||||||
|
<video
|
||||||
|
key={`${resolvedTheme}-${index}`}
|
||||||
|
ref={(el) => (videoRefs.current[index] = el)}
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
className="h-auto w-full rounded-xl"
|
||||||
|
>
|
||||||
|
<source
|
||||||
|
src={resolvedTheme === 'dark' ? slide.srcDark : slide.srcLight}
|
||||||
|
type="video/webm"
|
||||||
|
/>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dark:bg-background absolute bottom-2 right-2 flex w-[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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
apps/marketing/src/components/(marketing)/enterprise.tsx
Normal file
36
apps/marketing/src/components/(marketing)/enterprise.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -35,6 +35,7 @@ const FOOTER_LINKS = [
|
|||||||
{ href: '/oss-friends', text: 'OSS Friends' },
|
{ href: '/oss-friends', text: 'OSS Friends' },
|
||||||
{ href: '/careers', text: 'Careers' },
|
{ href: '/careers', text: 'Careers' },
|
||||||
{ href: '/privacy', text: 'Privacy' },
|
{ href: '/privacy', text: 'Privacy' },
|
||||||
|
{ href: '/changelog', text: 'Changelog' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
export const Footer = ({ className, ...props }: FooterProps) => {
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-fl
|
|||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { Widget } from './widget';
|
import { Carousel } from './carousel';
|
||||||
|
|
||||||
export type HeroProps = {
|
export type HeroProps = {
|
||||||
className?: string;
|
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) => {
|
export const Hero = ({ className, ...props }: HeroProps) => {
|
||||||
const event = usePlausible();
|
const event = usePlausible();
|
||||||
|
|
||||||
@ -57,23 +72,6 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
|
|
||||||
const heroMarketingCTA = getFlag('marketing_landing_hero_cta');
|
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 (
|
return (
|
||||||
<motion.div className={cn('relative', className)} {...props}>
|
<motion.div className={cn('relative', className)} {...props}>
|
||||||
<div className="absolute -inset-24 -z-10">
|
<div className="absolute -inset-24 -z-10">
|
||||||
@ -108,18 +106,18 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
animate="animate"
|
animate="animate"
|
||||||
className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4"
|
className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4"
|
||||||
>
|
>
|
||||||
<Button
|
<Link href="https://app.documenso.com/signup?utm_source=marketing-hero">
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
variant="outline"
|
||||||
onClick={onSignUpClick}
|
className="rounded-full bg-transparent backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
Claim Early Adopter Plan
|
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">
|
<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
|
No Credit Card required
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
|
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
|
||||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
||||||
<LuGithub className="mr-2 h-5 w-5" />
|
<LuGithub className="mr-2 h-5 w-5" />
|
||||||
@ -170,74 +168,11 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mt-12"
|
className="mt-12"
|
||||||
variants={{
|
variants={HeroCarouselVariants}
|
||||||
initial: {
|
|
||||||
scale: 0.2,
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
scale: 1,
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
ease: 'easeInOut',
|
|
||||||
delay: 0.5,
|
|
||||||
duration: 0.8,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
>
|
>
|
||||||
<Widget className="mt-12">
|
<Carousel />
|
||||||
<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>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
>
|
>
|
||||||
Yearly
|
Yearly
|
||||||
<div className="bg-muted text-foreground block rounded-full px-2 py-0.5 text-xs">
|
<div className="bg-muted text-foreground block rounded-full px-2 py-0.5 text-xs">
|
||||||
Save $60
|
Save $60 or $100
|
||||||
</div>
|
</div>
|
||||||
{period === 'YEARLY' && (
|
{period === 'YEARLY' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -75,7 +75,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
data-plan="free"
|
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"
|
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-primary mt-2.5 text-xl font-medium">$0</p>
|
||||||
|
|
||||||
<p className="text-foreground mt-4 max-w-[30ch] text-center">
|
<p className="text-foreground mt-4 max-w-[30ch] text-center">
|
||||||
@ -102,10 +102,10 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-plan="early-adopter"
|
data-plan="individual"
|
||||||
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]"
|
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">
|
<div className="text-primary mt-2.5 text-xl font-medium">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{period === 'MONTHLY' && <motion.div layoutId="pricing">$30</motion.div>}
|
{period === 'MONTHLY' && <motion.div layoutId="pricing">$30</motion.div>}
|
||||||
@ -114,12 +114,12 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-foreground mt-4 max-w-[30ch] text-center">
|
<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>
|
</p>
|
||||||
|
|
||||||
<Button className="mt-6 rounded-full text-base" asChild>
|
<Button className="mt-6 rounded-full text-base" asChild>
|
||||||
<Link
|
<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"
|
target="_blank"
|
||||||
>
|
>
|
||||||
Signup Now
|
Signup Now
|
||||||
@ -127,51 +127,46 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
<div className="mt-8 flex w-full flex-col divide-y">
|
||||||
<p className="text-foreground py-4">
|
<p className="text-foreground py-4">Unlimited Documents per Month</p>
|
||||||
<a
|
<p className="text-foreground py-4">API Accesss</p>
|
||||||
href="https://documen.so/early-adopters-pricing-page"
|
<p className="text-foreground py-4">Email and Discord Support</p>
|
||||||
target="_blank"
|
<p className="text-foreground py-4">Premium Profile Name</p>
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Limited Time Offer: <span className="text-documenso-700">Read More</span>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p className="text-foregro‚und py-4">Unlimited Teams</p>
|
|
||||||
<p className="text-foregro‚und py-4">Unlimited Users</p>
|
|
||||||
<p className="text-foregro‚und 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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-plan="enterprise"
|
data-plan="teams"
|
||||||
className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg"
|
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-foreground text-4xl font-medium">Teams</p>
|
||||||
<p className="text-primary mt-2.5 text-xl font-medium">Pricing on request</p>
|
<div className="text-primary mt-2.5 text-xl font-medium">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{period === 'MONTHLY' && <motion.div layoutId="pricingTeams">$50</motion.div>}
|
||||||
|
{period === 'YEARLY' && <motion.div layoutId="pricingTeams">$500</motion.div>}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-foreground mt-4 max-w-[30ch] text-center">
|
<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>
|
</p>
|
||||||
|
|
||||||
<Link
|
<Button className="mt-6 rounded-full text-base" asChild>
|
||||||
href="https://dub.sh/enterprise"
|
<Link
|
||||||
target="_blank"
|
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-teams-plan`}
|
||||||
className="mt-6"
|
target="_blank"
|
||||||
onClick={() => event('enterprise-contact')}
|
>
|
||||||
>
|
Signup Now
|
||||||
<Button className="rounded-full text-base">Contact Us</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
<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">Unlimited Documents per Month</p>
|
||||||
<p className="text-foreground py-4">Custom Subdomain</p>
|
<p className="text-foreground py-4">API Accesss</p>
|
||||||
<p className="text-foreground py-4">Compliance Check</p>
|
<p className="text-foreground py-4">Email and Discord Support</p>
|
||||||
<p className="text-foreground py-4">Guaranteed Uptime</p>
|
<p className="text-foreground py-4 font-medium">Team Inbox</p>
|
||||||
<p className="text-foreground py-4">Reporting & Analysis</p>
|
<p className="text-foreground py-4">5 Users Included</p>
|
||||||
<p className="text-foreground py-4">24/7 Support</p>
|
<p className="text-foreground py-4">Add More Users for $10/ mo.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
29
apps/marketing/src/components/(marketing)/slide.tsx
Normal file
29
apps/marketing/src/components/(marketing)/slide.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 ">
|
|
||||||
What’s 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -19,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
|
|||||||
|
|
||||||
const [{ users, totalPages }, individualPrices] = await Promise.all([
|
const [{ users, totalPages }, individualPrices] = await Promise.all([
|
||||||
search(searchString, page, perPage),
|
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);
|
const individualPriceIds = individualPrices.map((price) => price.id);
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export default async function BillingSettingsPage() {
|
|||||||
|
|
||||||
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
|
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
|
||||||
getSubscriptionsByUserId({ userId: user.id }),
|
getSubscriptionsByUserId({ userId: user.id }),
|
||||||
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
|
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.REGULAR }),
|
||||||
getPrimaryAccountPlanPrices(),
|
getPrimaryAccountPlanPrices(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import type { Field } from '@documenso/prisma/client';
|
import type { Field } from '@documenso/prisma/client';
|
||||||
import { type Recipient } from '@documenso/prisma/client';
|
import { type Recipient } from '@documenso/prisma/client';
|
||||||
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||||
@ -47,6 +48,8 @@ export const DirectTemplatePageView = ({
|
|||||||
const [step, setStep] = useState<DirectTemplateStep>('configure');
|
const [step, setStep] = useState<DirectTemplateStep>('configure');
|
||||||
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
||||||
|
|
||||||
|
const recipientRoleDescription = RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role];
|
||||||
|
|
||||||
const directTemplateFlow: Record<DirectTemplateStep, DocumentFlowStep> = {
|
const directTemplateFlow: Record<DirectTemplateStep, DocumentFlowStep> = {
|
||||||
configure: {
|
configure: {
|
||||||
title: 'General',
|
title: 'General',
|
||||||
@ -54,8 +57,8 @@ export const DirectTemplatePageView = ({
|
|||||||
stepIndex: 1,
|
stepIndex: 1,
|
||||||
},
|
},
|
||||||
sign: {
|
sign: {
|
||||||
title: 'Sign document',
|
title: `${recipientRoleDescription.actionVerb} document`,
|
||||||
description: 'Sign the document to complete the process.',
|
description: `${recipientRoleDescription.actionVerb} the document to complete the process.`,
|
||||||
stepIndex: 2,
|
stepIndex: 2,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
36
package-lock.json
generated
36
package-lock.json
generated
@ -45,6 +45,9 @@
|
|||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
"@openstatus/react": "^0.0.3",
|
"@openstatus/react": "^0.0.3",
|
||||||
"contentlayer": "^0.3.4",
|
"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",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
@ -11952,6 +11955,39 @@
|
|||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.593.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.593.tgz",
|
||||||
"integrity": "sha512-c7+Hhj87zWmdpmjDONbvNKNo24tvmD4mjal1+qqTYTrlF0/sNpAcDlU0Ki84ftA/5yj3BF2QhSGEC0Rky6larg=="
|
"integrity": "sha512-c7+Hhj87zWmdpmjDONbvNKNo24tvmD4mjal1+qqTYTrlF0/sNpAcDlU0Ki84ftA/5yj3BF2QhSGEC0Rky6larg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/embla-carousel": {
|
||||||
|
"version": "8.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.1.3.tgz",
|
||||||
|
"integrity": "sha512-GiRpKtzidV3v50oVMly8S+D7iE1r96ttt7fSlvtyKHoSkzrAnVcu8fX3c4j8Ol2hZSQlVfDqDIqdrFPs0u5TWQ=="
|
||||||
|
},
|
||||||
|
"node_modules/embla-carousel-autoplay": {
|
||||||
|
"version": "8.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/embla-carousel-autoplay/-/embla-carousel-autoplay-8.1.3.tgz",
|
||||||
|
"integrity": "sha512-nMPuOZ+f3yp/RzUEYDOWjO7EkhdNHfdxEoRxfwqIvTGdSQ04LAFAnMLiLWSetAXzB1bP30L391mZb9keZXRcWQ==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"embla-carousel": "8.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/embla-carousel-react": {
|
||||||
|
"version": "8.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.1.3.tgz",
|
||||||
|
"integrity": "sha512-YrezDPgxPDKa+OKMhSrwuPEU2OgF5147vFW473EWT3bx9DETV3W/RyWTxq0/2pf3M4VXkjqFNbS/W1xM8lTaVg==",
|
||||||
|
"dependencies": {
|
||||||
|
"embla-carousel": "8.1.3",
|
||||||
|
"embla-carousel-reactive-utils": "8.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.1 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/embla-carousel-reactive-utils": {
|
||||||
|
"version": "8.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.1.3.tgz",
|
||||||
|
"integrity": "sha512-D8tAK6NRQVEubMWb+b/BJ3VvGPsbEeEFOBM6cCCwfiyfLzNlacOAt0q2dtUEA9DbGxeWkB8ExgXzFRxhGV2Hig==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"embla-carousel": "8.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
|||||||
@ -6,5 +6,5 @@ import { getPricesByPlan } from './get-prices-by-plan';
|
|||||||
* Returns the Stripe prices of items that affect the amount of documents a user can create.
|
* Returns the Stripe prices of items that affect the amount of documents a user can create.
|
||||||
*/
|
*/
|
||||||
export const getDocumentRelatedPrices = async () => {
|
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]);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||||
|
|
||||||
// Utility type to handle usage of the `expand` option.
|
// Utility type to handle usage of the `expand` option.
|
||||||
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
|
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
|
||||||
@ -11,7 +12,7 @@ export type GetPricesByIntervalOptions = {
|
|||||||
/**
|
/**
|
||||||
* Filter products by their meta 'plan' attribute.
|
* Filter products by their meta 'plan' attribute.
|
||||||
*/
|
*/
|
||||||
plan?: 'community';
|
plan?: STRIPE_PLAN_TYPE.COMMUNITY | STRIPE_PLAN_TYPE.REGULAR;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPricesByInterval = async ({ plan }: GetPricesByIntervalOptions = {}) => {
|
export const getPricesByInterval = async ({ plan }: GetPricesByIntervalOptions = {}) => {
|
||||||
|
|||||||
@ -6,5 +6,5 @@ import { getPricesByPlan } from './get-prices-by-plan';
|
|||||||
* Returns the prices of items that count as the account's primary plan.
|
* Returns the prices of items that count as the account's primary plan.
|
||||||
*/
|
*/
|
||||||
export const getPrimaryAccountPlanPrices = async () => {
|
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]);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import config from '@documenso/tailwind-config';
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -14,9 +15,11 @@ import {
|
|||||||
} from '../components';
|
} from '../components';
|
||||||
import TemplateDocumentImage from '../template-components/template-document-image';
|
import TemplateDocumentImage from '../template-components/template-document-image';
|
||||||
import { TemplateFooter } from '../template-components/template-footer';
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
|
import { RecipientRole } from '.prisma/client';
|
||||||
|
|
||||||
export type DocumentCompletedEmailTemplateProps = {
|
export type DocumentCompletedEmailTemplateProps = {
|
||||||
recipientName?: string;
|
recipientName?: string;
|
||||||
|
recipientRole?: RecipientRole;
|
||||||
documentLink?: string;
|
documentLink?: string;
|
||||||
documentName?: string;
|
documentName?: string;
|
||||||
assetBaseUrl?: string;
|
assetBaseUrl?: string;
|
||||||
@ -24,11 +27,14 @@ export type DocumentCompletedEmailTemplateProps = {
|
|||||||
|
|
||||||
export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
|
export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
|
||||||
recipientName = 'John Doe',
|
recipientName = 'John Doe',
|
||||||
|
recipientRole = RecipientRole.SIGNER,
|
||||||
documentLink = 'http://localhost:3000',
|
documentLink = 'http://localhost:3000',
|
||||||
documentName = 'Open Source Pledge.pdf',
|
documentName = 'Open Source Pledge.pdf',
|
||||||
assetBaseUrl = 'http://localhost:3002',
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
}: DocumentCompletedEmailTemplateProps) => {
|
}: DocumentCompletedEmailTemplateProps) => {
|
||||||
const previewText = `Completed Document`;
|
const action = RECIPIENT_ROLES_DESCRIPTION[recipientRole].actioned.toLowerCase();
|
||||||
|
|
||||||
|
const previewText = `Document created from direct template`;
|
||||||
|
|
||||||
const getAssetUrl = (path: string) => {
|
const getAssetUrl = (path: string) => {
|
||||||
return new URL(path, assetBaseUrl).toString();
|
return new URL(path, assetBaseUrl).toString();
|
||||||
@ -61,7 +67,7 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
|
|||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Text className="text-primary mb-0 text-center text-lg font-semibold">
|
<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>
|
</Text>
|
||||||
|
|
||||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-sm text-slate-600">
|
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-sm text-slate-600">
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps)
|
|||||||
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
|
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
|
||||||
|
|
||||||
downloadFile({
|
downloadFile({
|
||||||
filename: `${baseTitle}.pdf`,
|
filename: `${baseTitle}_signed.pdf`,
|
||||||
data: blob,
|
data: blob,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export enum STRIPE_CUSTOMER_TYPE {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum STRIPE_PLAN_TYPE {
|
export enum STRIPE_PLAN_TYPE {
|
||||||
|
REGULAR = 'regular',
|
||||||
TEAM = 'team',
|
TEAM = 'team',
|
||||||
COMMUNITY = 'community',
|
COMMUNITY = 'community',
|
||||||
ENTERPRISE = 'enterprise',
|
ENTERPRISE = 'enterprise',
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
||||||
import fontkit from '@pdf-lib/fontkit';
|
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 {
|
import {
|
||||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
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`);
|
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 fieldWidth = pageWidth * (Number(field.width) / 100);
|
||||||
const fieldHeight = pageHeight * (Number(field.height) / 100);
|
const fieldHeight = pageHeight * (Number(field.height) / 100);
|
||||||
@ -65,17 +91,31 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
imageWidth = imageWidth * scalingFactor;
|
imageWidth = imageWidth * scalingFactor;
|
||||||
imageHeight = imageHeight * scalingFactor;
|
imageHeight = imageHeight * scalingFactor;
|
||||||
|
|
||||||
const imageX = fieldX + (fieldWidth - imageWidth) / 2;
|
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
|
||||||
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
|
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
|
||||||
|
|
||||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||||
imageY = pageHeight - imageY - imageHeight;
|
imageY = pageHeight - imageY - imageHeight;
|
||||||
|
|
||||||
|
if (pageRotationInDegrees !== 0) {
|
||||||
|
const adjustedPosition = adjustPositionForRotation(
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
imageX,
|
||||||
|
imageY,
|
||||||
|
pageRotationInDegrees,
|
||||||
|
);
|
||||||
|
|
||||||
|
imageX = adjustedPosition.xPos;
|
||||||
|
imageY = adjustedPosition.yPos;
|
||||||
|
}
|
||||||
|
|
||||||
page.drawImage(image, {
|
page.drawImage(image, {
|
||||||
x: imageX,
|
x: imageX,
|
||||||
y: imageY,
|
y: imageY,
|
||||||
width: imageWidth,
|
width: imageWidth,
|
||||||
height: imageHeight,
|
height: imageHeight,
|
||||||
|
rotate: degrees(pageRotationInDegrees),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const longestLineInTextForWidth = field.customText
|
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);
|
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
||||||
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||||
|
|
||||||
const textX = fieldX + (fieldWidth - textWidth) / 2;
|
let textX = fieldX + (fieldWidth - textWidth) / 2;
|
||||||
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
||||||
|
|
||||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||||
textY = pageHeight - textY - textHeight;
|
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, {
|
page.drawText(field.customText, {
|
||||||
x: textX,
|
x: textX,
|
||||||
y: textY,
|
y: textY,
|
||||||
size: fontSize,
|
size: fontSize,
|
||||||
font,
|
font,
|
||||||
|
rotate: degrees(pageRotationInDegrees),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,3 +171,32 @@ export const insertFieldInPDFBytes = async (
|
|||||||
|
|
||||||
return await pdfDoc.save();
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@ -480,6 +480,7 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
// Send email to template owner.
|
// Send email to template owner.
|
||||||
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
|
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
|
||||||
recipientName: directRecipientEmail,
|
recipientName: directRecipientEmail,
|
||||||
|
recipientRole: directTemplateRecipient.role,
|
||||||
documentLink: `${formatDocumentsPath(document.team?.url)}/${document.id}`,
|
documentLink: `${formatDocumentsPath(document.team?.url)}/${document.id}`,
|
||||||
documentName: document.title,
|
documentName: document.title,
|
||||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
|
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
|
||||||
|
|||||||
Reference in New Issue
Block a user