Compare commits
110 Commits
feat/compl
...
feat-early
| Author | SHA1 | Date | |
|---|---|---|---|
| 833584bd4f | |||
| ca52a22bcd | |||
| ba611b9adb | |||
| e8643850a0 | |||
| bec7376fe8 | |||
| 7363bbc8f7 | |||
| d0f679e177 | |||
| e9769904ab | |||
| a2746742a7 | |||
| 459e3dc45b | |||
| 21fedab707 | |||
| 323f1974ab | |||
| 5bc9d625c2 | |||
| 4885cf5154 | |||
| 2f66eca925 | |||
| af042a62cd | |||
| 2bfc2b0c1f | |||
| df8bdda718 | |||
| 873f99ae86 | |||
| 17fe135027 | |||
| 2eed0ae063 | |||
| f4ae0389d8 | |||
| 9bdff9a61f | |||
| f003a2864e | |||
| cdb71c3a62 | |||
| 39d7b3ca58 | |||
| 748c3636d5 | |||
| 6e2d1fb148 | |||
| 7bd847c0d3 | |||
| 81f9b2f776 | |||
| 334671ef85 | |||
| a20b1e2f6a | |||
| 572f9d5ad6 | |||
| e49c102e8c | |||
| 2348221b03 | |||
| a0c327cfcf | |||
| ddbeb9e3db | |||
| e8205c1390 | |||
| fbb53fc8c3 | |||
| d7fed5a5dc | |||
| 3285881586 | |||
| 3ff61607a2 | |||
| 2a124b03e9 | |||
| e1aa23bc55 | |||
| 818c5c7ba4 | |||
| 668011d1c7 | |||
| 562fd043a9 | |||
| b4781c011c | |||
| 5c7d3d5503 | |||
| 0f2b6c0ebf | |||
| ab5bdfeae4 | |||
| e5fa2f4490 | |||
| 2d2615447e | |||
| 46465acd73 | |||
| 2dea9ec3e7 | |||
| 584d5bd8ea | |||
| e4b6d42672 | |||
| 109ad190cf | |||
| 78498793fa | |||
| b2e916378d | |||
| d950634aab | |||
| a52c19355a | |||
| e67e96333b | |||
| 7aa75b0c64 | |||
| 9b1069f208 | |||
| abe20b8dcf | |||
| 58509c54a9 | |||
| fd545cee0c | |||
| 1f92bffe7d | |||
| 5615627001 | |||
| 78c55875ef | |||
| a3674947b8 | |||
| 63cc57b035 | |||
| d4bc1eb0d1 | |||
| c9f5496acb | |||
| 99481b6144 | |||
| cee147bc9a | |||
| 3a825391b9 | |||
| bb347e4614 | |||
| d0fad4775a | |||
| 8012d665ae | |||
| 0732aa317d | |||
| fde53e355f | |||
| 8e0a10298e | |||
| eef63c9adc | |||
| c3d52c68e7 | |||
| 764c6b88c5 | |||
| 989d7a351f | |||
| 679fb80f9a | |||
| dc49277bf9 | |||
| 190ae18edc | |||
| 82ffbeeb21 | |||
| 9affd7b7fa | |||
| 897ba629df | |||
| 7eed5c7c96 | |||
| 1c629af651 | |||
| 4b8aa3298b | |||
| eaa3e0a303 | |||
| a283c88d7f | |||
| c2a9647c90 | |||
| 02424596db | |||
| 5809109c05 | |||
| a6400eb6c9 | |||
| 39958ed22c | |||
| 7c4ba1b1ea | |||
| f588897531 | |||
| 5629e08f83 | |||
| 37394c054c | |||
| b7c0df67b1 | |||
| e47ab838c5 |
@ -6,7 +6,7 @@ NEXTAUTH_SECRET="secret"
|
|||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||||
|
|
||||||
# [[APP]]
|
# [[URLS]]
|
||||||
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||||
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
|
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
|
||||||
|
|
||||||
@ -50,7 +50,9 @@ NEXT_PRIVATE_SMTP_SECURE=
|
|||||||
NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso"
|
NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso"
|
||||||
# REQUIRED: Defines the email address to use as the from address.
|
# REQUIRED: Defines the email address to use as the from address.
|
||||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
|
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
|
||||||
# OPTIONAL: The API key to use for the MailChannels proxy endpoint.
|
# OPTIONAL: The API key to use for Resend.com
|
||||||
|
NEXT_PRIVATE_RESEND_API_KEY=
|
||||||
|
# OPTIONAL: The API key to use for MailChannels.
|
||||||
NEXT_PRIVATE_MAILCHANNELS_API_KEY=
|
NEXT_PRIVATE_MAILCHANNELS_API_KEY=
|
||||||
# OPTIONAL: The endpoint to use for the MailChannels API if using a proxy.
|
# OPTIONAL: The endpoint to use for the MailChannels API if using a proxy.
|
||||||
NEXT_PRIVATE_MAILCHANNELS_ENDPOINT=
|
NEXT_PRIVATE_MAILCHANNELS_ENDPOINT=
|
||||||
|
|||||||
6
.github/dependabot.yml
vendored
@ -9,7 +9,7 @@ updates:
|
|||||||
labels:
|
labels:
|
||||||
- "ci dependencies"
|
- "ci dependencies"
|
||||||
- "ci"
|
- "ci"
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 2
|
||||||
|
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/apps/marketing"
|
directory: "/apps/marketing"
|
||||||
@ -19,7 +19,7 @@ updates:
|
|||||||
labels:
|
labels:
|
||||||
- "npm dependencies"
|
- "npm dependencies"
|
||||||
- "frontend"
|
- "frontend"
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 2
|
||||||
|
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/apps/web"
|
directory: "/apps/web"
|
||||||
@ -29,4 +29,4 @@ updates:
|
|||||||
labels:
|
labels:
|
||||||
- "npm dependencies"
|
- "npm dependencies"
|
||||||
- "frontend"
|
- "frontend"
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 2
|
||||||
|
|||||||
35
README.md
@ -1,6 +1,6 @@
|
|||||||
<p align="center" style="margin-top: 120px">
|
<p align="center" style="margin-top: 120px">
|
||||||
<a href="https://github.com/documenso/documenso">
|
<a href="https://github.com/documenso/documenso">
|
||||||
<img width="250px" src="https://github.com/documenso/documenso/assets/1309312/cd7823ec-4baa-40b9-be78-4acb3b1c73cb" alt="Documenso Logo">
|
<img width="250px" src="https://github.com/documenso/documenso/assets/1309312/ea8de2d0-8e33-4527-8a5c-40780142a2ed" alt="Documenso Logo">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@ -26,37 +26,30 @@
|
|||||||
<a href="https://github.com/documenso/documenso/pulse"><img src="https://img.shields.io/github/commit-activity/m/documenso/documenso" alt="Commits-per-month"></a>
|
<a href="https://github.com/documenso/documenso/pulse"><img src="https://img.shields.io/github/commit-activity/m/documenso/documenso" alt="Commits-per-month"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> **🚧 We're currently working on a large scale refactor which can be found on the [feat/refresh](https://github.com/documenso/documenso/tree/feat/refresh) branch.**
|
> 🦺 Documenso 1.0 is deployed to our <a href="https://documen.so/staging" target="_blank">Staging Environment</a>.
|
||||||
>
|
>
|
||||||
> **[Read more on why 👀](https://documenso.com/blog/why-were-doing-a-rewrite)**
|
> The code can be found on the [feat/refresh](https://github.com/documenso/documenso/tree/feat/refresh) branch.
|
||||||
|
>
|
||||||
|
> The new Version will be released after the current testing phase.
|
||||||
|
|
||||||
# Documenso 0.9 - Developer Preview
|
|
||||||
|
|
||||||
|
# Join us in testing Documenso 1.0 during [MALFUNCTION MANIA](https://documenso.com/blog/malfunction-mania)
|
||||||
<div>
|
<div>
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
<img style="display: block; height: 120px; width: 24%"
|
||||||
src="https://user-images.githubusercontent.com/1309312/224570645-167128ee-3e39-4578-85d2-5394d9a0379c.png">
|
src="https://github.com/documenso/documenso/assets/1309312/67e08c98-c153-4115-aa2d-77979bb12c94)">
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
<img style="display: block; height: 120px; width: 24%"
|
||||||
src="https://user-images.githubusercontent.com/1309312/224570651-0afd12f8-cfe3-49d1-805e-e495af963d91.png">
|
src="https://github.com/documenso/documenso/assets/1309312/040cfbae-3438-4ca3-87f2-ce52c793dcaf">
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
<img style="display: block; height: 120px; width: 24%"
|
||||||
src="https://user-images.githubusercontent.com/1309312/224570655-328d2279-058d-4a3e-b5c3-5cbd8a1f4e05.png">
|
src="https://github.com/documenso/documenso/assets/1309312/72d445be-41e5-4936-bdba-87ef8e70fa09">
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
<img style="display: block; height: 120px; width: 24%"
|
||||||
src="https://user-images.githubusercontent.com/1309312/224571617-1f3c2811-c1ac-4d7d-b9b0-4ab183731405.png">
|
src="https://github.com/documenso/documenso/assets/1309312/d7b86c0f-a755-4476-a022-a608db2c4633">
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
<img style="display: block; height: 120px; width: 24%"
|
||||||
src="https://user-images.githubusercontent.com/1309312/224570322-b2c76ea8-7482-4043-ad97-f1221220c591.png">
|
src=https://github.com/documenso/documenso/assets/1309312/c0f55116-ab82-433f-a266-f3fc8571d69f">
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
|
||||||
src="https://user-images.githubusercontent.com/1309312/224570325-a8055f24-9826-4a23-b116-4fbb0577581a.png">
|
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
|
||||||
src="https://user-images.githubusercontent.com/1309312/224570318-f724bbd9-c394-4bdc-bace-2d78af92de44.png">
|
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
|
||||||
src="https://user-images.githubusercontent.com/1309312/224571539-f019b860-f613-4b20-86e8-4437c5784265.png">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **Note**
|
|
||||||
> This project is currently under community review and will publish it's first production release soon™.
|
|
||||||
|
|
||||||
## About this project
|
## About this project
|
||||||
|
|
||||||
Signing documents digitally is fast, easy and should be best practice for every document signed worldwide. This is technically quite easy today, but it also introduces a new party to every signature: The signing tool providers. While this is not a problem in itself, it should make us think about how we want these providers of trust to work. Documenso aims to be the world's most trusted document signing tool. This trust is built by empowering you to self-host Documenso and review how it works under the hood. Join us in creating the next generation of open trust infrastructure.
|
Signing documents digitally is fast and easy and should be the best practice for every document signed worldwide. This is technically quite easy today, but it also introduces a new party to every signature: The signing tool providers. While this is not a problem in itself, it should make us think about how we want these providers of trust to work. Documenso aims to be the world's most trusted document-signing tool. This trust is built by empowering you to self-host Documenso and review how it works under the hood. Join us in creating the next generation of open trust infrastructure.
|
||||||
|
|
||||||
## Recognition
|
## Recognition
|
||||||
|
|
||||||
@ -65,13 +58,13 @@ Signing documents digitally is fast, easy and should be best practice for every
|
|||||||
|
|
||||||
## Community and Next Steps 🎯
|
## Community and Next Steps 🎯
|
||||||
|
|
||||||
We're currently working on a redesign of the application including a revamp of the codebase so Documenso can be more intuitive to use and robust to develop upon.
|
We're currently working on a redesign of the application, including a revamp of the codebase so Documenso can be more intuitive to use and robust to develop upon.
|
||||||
|
|
||||||
- Check out the first source code release in this repository and test it
|
- Check out the first source code release in this repository and test it
|
||||||
- Tell us what you think in the current [Discussions](https://github.com/documenso/documenso/discussions)
|
- Tell us what you think in the current [Discussions](https://github.com/documenso/documenso/discussions)
|
||||||
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know to other community members
|
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know to other community members
|
||||||
- ⭐ the repository to help us raise awareness
|
- ⭐ the repository to help us raise awareness
|
||||||
- Spread the word on Twitter, that Documenso is working towards a more open signing tool
|
- Spread the word on Twitter that Documenso is working towards a more open signing tool
|
||||||
- Fix or create [issues](https://github.com/documenso/documenso/issues), that are needed for the first production release
|
- Fix or create [issues](https://github.com/documenso/documenso/issues), that are needed for the first production release
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
67
apps/marketing/content/blog/early-adopters.mdx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
title: The Early Adopters Plan
|
||||||
|
description: Launch Week Day 4 and we are still early! Early enough for you to get a sweet deal for supporting Documenso's Mission. Join the movement and get a shiny early adopter account in the process.
|
||||||
|
authorName: 'Timur Ercan'
|
||||||
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2023-09-28
|
||||||
|
tags:
|
||||||
|
- Paid Plan
|
||||||
|
- Metrics
|
||||||
|
- Open Startup
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/early.png"
|
||||||
|
width="1260"
|
||||||
|
height="630"
|
||||||
|
alt="XKCD: Bug"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
"Being early is, uh, good." -Unknown
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
> TLDR; we have 100 early adopter accounts available at a great price. [Secure yours now](https://documenso.com/pricing).
|
||||||
|
|
||||||
|
## Community-Driven Development
|
||||||
|
|
||||||
|
As we ramp up hiring and development speed for Documenso, I want to discuss how we plan to build its core version.
|
||||||
|
As we want to be a highly community-driven product, we want to capture the thoughts and ideas of said community in two ways:
|
||||||
|
|
||||||
|
1. Everything we plan (i.e. documen.so/roadmap) and build (i.e. documen.so/repo) is public and open to comments and suggestions from everyone by design. We plan to have discussions, streams, and articles showcasing what we're up to, encouraging feedback and iterations on our plans.
|
||||||
|
2. We're looking for 100 die-hard early adopters to get more deep hands-on feedback. If you want to be among the first to use and help shape Documenso, we have a special offer for you:
|
||||||
|
|
||||||
|
## The Early Adopter Plan
|
||||||
|
|
||||||
|
- All first 100 signups are entitled to the early adopter plan.
|
||||||
|
- The plan includes everything we build in the next 12 months and unlimited<sup>1</sup> signatures
|
||||||
|
- This plan is priced at $30/mo.
|
||||||
|
- No matter what we add, the price is guaranteed for life as a thank you for supporting Documenso's mission<sup>2</sup>. And we plan to add a lot.
|
||||||
|
- This also includes unlimited users<sup>3</sup> as part of the upcoming support for teams.
|
||||||
|
- If you already claimed an early adopter account in the past and canceled, we are happy to reactivate your account with the early adopter pricing. Reach out to support@documenso.com
|
||||||
|
|
||||||
|
## Being an Early Adopter
|
||||||
|
|
||||||
|
Being eligible for the early adopter plan has no formal requirements like giving feedback or being active in the community. In good faith, we assume you sign the Documenso Supporter Pledge to be part of this. And if you want to use the newest version quietly, that's fine, too. Everything the die-hard community brings up will be carefully considered and prioritized <sup>4</sup>. Also, there will obviously be limited edition merch available for the first 100.
|
||||||
|
Documenso currently runs the community reviewed 0.9.1 version. Getting from here to the globally loved and adopted signing tool we all deserve will take a lot of work, and we want you on board to help us create it. Join us in shaping the future of open signing and having fun doing it. [Malfunction Mania](https://documenso.com/blog/malfunction-mania) and releasing 1.0 will go a long way.
|
||||||
|
|
||||||
|
## Extending our open metrics
|
||||||
|
|
||||||
|
As part of our ongoing effort to be open and transparent in our doing, we are adding "Early Adopters" to our [/open page](https://documenso.com/open) page. After we exceed the early adopter slots, this metric will transition to "Customers". When no more early adopter seats can be claimed, the early adopter plan will transition to a standard paid plan. It will still be priced at $30/mo., but will no longer include upcoming features or unlimited seats. You can [claim your early adopter account here](https://documenso.com/pricing).
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
\
|
||||||
|
[1] Excluding signatures, we have to pay for i.e. third-party QES
|
||||||
|
|
||||||
|
[2] The Documenso Manifest
|
||||||
|
|
||||||
|
[3] Within reason. If you are unsure what that means, feel free to contact hi@documenso.com. But it should be fine if you don't plan to onboard a huge enterprise.
|
||||||
|
|
||||||
|
[4] We won't be able to build everything everyone asks for. But we firmly plan to listen and build in a way everyone's requirements are met as well as possible.
|
||||||
57
apps/marketing/content/blog/malfunction-mania.mdx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
title: Announcing Malfunction Mania
|
||||||
|
description: Launch Week Day 2 Y'all! We're getting ready to release Documenso 1.0! Join in on the fun of making sure the open-source alternative to DocuSign is on point. We're calling a 'MALFUNCTION MANIA.'
|
||||||
|
authorName: 'Timur Ercan'
|
||||||
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2023-09-26
|
||||||
|
tags:
|
||||||
|
- Testing
|
||||||
|
- Rewrite
|
||||||
|
- Bounties
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/mm.png"
|
||||||
|
width="1260"
|
||||||
|
height="630"
|
||||||
|
alt="Malfunctioning Documenso Logo inbroken colors"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
We're calling a MALFUNCTION MANIA! 🚨
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
> TLDR; Documenso 1.0 is in the [staging environment](https://documen.so/staging). Go check it out.
|
||||||
|
|
||||||
|
It's been a minute since Lucas proclaimed, ["We're doing a rewrite"](https://documenso.com/blog/why-were-doing-a-rewrite), and many of you have been asking when the new version will be available. I'm happy to say that the wait has come to an end. The work on Documenso 1.0 has reached a level we feel comfortable going into the next phase with. We had a lot of community feedback, contributions, and moral support to get us this far, which is why we're excited to announce the most extensive community project yet:
|
||||||
|
|
||||||
|
As Documenso 1.0 just hit the staging environment, we're calling a MALFUNCTION MANIA. An enormous, public testing phase, where we invite everyone to try out the new version, hunt down, report, and fix any malfunctions (aka bugs), and give feedback before release. Malfunction Mania will happen alongside our internal testing, and by combining the two, we want to ensure the best possible release we can have for Version 1.0. We know many of you have been eager to contribute; this is your chance (the first of many to come).
|
||||||
|
|
||||||
|
## As part of Malfunction Mania, we're offering special bug bounties
|
||||||
|
|
||||||
|
- We award $25 - $100 per report/ issue/ fix, depending on the severity and if the problem is already known
|
||||||
|
- Bounties will be awarded for fixing reported or other critical issues via accepted Pull Requests (PR)
|
||||||
|
- Just reporting issues in a reproducible way can also be awarded
|
||||||
|
- Smaller but notable contributions like minor issues and documentation will be awarded with exclusive merch as we see fit.
|
||||||
|
|
||||||
|
## What you can do
|
||||||
|
|
||||||
|
- Head over to the [staging environment](https://documen.so/staging), check out the new version and give it a spin;
|
||||||
|
- Check out the [source code](https://github.com/documenso/documenso) on GitHub and look it over;
|
||||||
|
- Spin up the new version locally and try it out.
|
||||||
|
|
||||||
|
## How to get the bounties
|
||||||
|
|
||||||
|
- Report bugs by creating an issue here: [documen.so/issues](https://documen.so/issues);
|
||||||
|
- Fix bugs by creating a Pull Request (PR);
|
||||||
|
- Look over and add missing documentation/ Quickstarts and other useful resources.
|
||||||
|
|
||||||
|
We don't have a specific end date for Malfunction Mania. We plan to move the staging version into the production environment by the end of the month once we're happy with the results. Bug reports and fixes are, of course, always welcome going forward.
|
||||||
|
|
||||||
|
Best from Hamburg
|
||||||
|
Timur
|
||||||
|
|
||||||
|
**[Follow Documenso on Twitter / X](https://documen.so/tw) and [join the Discord server](https://documen.so/discord) to get the latest about Malfunction Mania.**
|
||||||
@ -20,11 +20,11 @@ Today, I'm pleased to share with you a preview of the next Documenso.
|
|||||||
|
|
||||||
We redesigned the whole signing flow to make it more appealing and more convenient.
|
We redesigned the whole signing flow to make it more appealing and more convenient.
|
||||||
|
|
||||||
We improved the overall look and feel by making it more elegant and appropriately playful. Focused on the task at hand, but explicitly enjoying doing it.
|
We improved the overall look and feel by making it more elegant and appropriately playful. Focused on the task at hand, but explicitly enjoying doing it.
|
||||||
|
|
||||||
**We call it happy minimalism.**
|
**We call it happy minimalism.**
|
||||||
|
|
||||||
We paid particular attention to the moment of signing, which should be celebrated.
|
We paid particular attention to the moment of signing, which should be celebrated.
|
||||||
|
|
||||||
The image below is the final bloom of the completion celebration we added:
|
The image below is the final bloom of the completion celebration we added:
|
||||||
|
|
||||||
|
|||||||
64
apps/marketing/content/blog/shop.mdx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
title: Merch Mania
|
||||||
|
description: Happy Launch Week Day 3. The limited edition "Malfunction Mania" shirt is here. Grab it, while you can.
|
||||||
|
authorName: 'Timur Ercan'
|
||||||
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2023-09-27
|
||||||
|
tags:
|
||||||
|
- Merch
|
||||||
|
- Rewrite
|
||||||
|
- Bounties
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/mania-shirt.png"
|
||||||
|
width="1260"
|
||||||
|
height="630"
|
||||||
|
alt="Malfunctioning Mania Themed Documenso Shirt"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
The Limited Edition "Malfunction Mania" Shirt - Only during Malfunction Mania
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
> TLDR; We have a fancy limited edition shirt. Contribute to [Malfunction Mania](https://documenso.com/blog/malfunction-mania) to get one.
|
||||||
|
|
||||||
|
We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania) yesterday, and the first [issues](https://github.com/documenso/documenso/issues) are coming in. As mentioned, there will be dollar bounties, but we also wanted to celebrate entering the final stage of version 1.0 with something special. This is why we created this limited edition shirt. It will only be available during the runtime of Malfunction Mania. We have yet to set an exact end date, the next event in October, however, is looming, ready to end MM.
|
||||||
|
|
||||||
|
## Documenso Merch Shop
|
||||||
|
|
||||||
|
The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contrinuting to Documenso.
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/shop.png"
|
||||||
|
width="1260"
|
||||||
|
height="630"
|
||||||
|
alt="Malfunctioning Mania Themed Documenso Shirt"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
Merch at Documenso is always given to those who deserve it.
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
## How earn the shirt
|
||||||
|
|
||||||
|
If you have been following us, you know we are not big on formalities but highly value rewarding merit. That being said, any worthwhile contribution has a chance to get one. To inspire, here are a few ideas on how to contribute to securing one:
|
||||||
|
|
||||||
|
- Report a bug with detailed reproduction details
|
||||||
|
- Fix a bug (you or somebody else reported)
|
||||||
|
- Analyze and describe a usability or user experience shortcoming
|
||||||
|
- Test the product in a systematic and least somewhat documented way
|
||||||
|
- Engage in discussion about the current version and its choices
|
||||||
|
- Raise awareness for Malfunction Mania and try out the [version currently in staging](https://documen.so/staging)
|
||||||
|
- Review the version with a video, stream, or screenshots and post about it
|
||||||
|
- Review existing or create missing documentation
|
||||||
|
|
||||||
|
Best from Hamburg
|
||||||
|
Timur
|
||||||
|
|
||||||
|
**[Follow Documenso on Twitter / X](https://documen.so/tw) and [join the Discord server](https://documen.so/discord) to get the latest updates about Malfunction Mania.**
|
||||||
BIN
apps/marketing/example/cert.p12
Normal file
@ -2,7 +2,7 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { withContentlayer } = require('next-contentlayer');
|
const { withContentlayer } = require('next-contentlayer');
|
||||||
|
|
||||||
const { parsed: env } = require('dotenv').config({
|
require('dotenv').config({
|
||||||
path: path.join(__dirname, '../../.env.local'),
|
path: path.join(__dirname, '../../.env.local'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -10,9 +10,13 @@ const { parsed: env } = require('dotenv').config({
|
|||||||
const config = {
|
const config = {
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: true,
|
serverActions: true,
|
||||||
|
serverActionsBodySizeLimit: '10mb',
|
||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_PROJECT: 'marketing',
|
||||||
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||||
@ -52,6 +56,14 @@ const config = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/ingest/:path*',
|
||||||
|
destination: 'https://eu.posthog.com/:path*',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withContentlayer(config);
|
module.exports = withContentlayer(config);
|
||||||
|
|||||||
@ -26,7 +26,9 @@
|
|||||||
"next-contentlayer": "^0.3.4",
|
"next-contentlayer": "^0.3.4",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
|
"posthog-js": "^1.77.3",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
"react-confetti": "^6.1.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.8.0",
|
||||||
|
|||||||
BIN
apps/marketing/public/blog/blog-author-lucas.png
Normal file
|
After Width: | Height: | Size: 1007 KiB |
BIN
apps/marketing/public/blog/blog-banner-rewrite.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
apps/marketing/public/blog/designsystem.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
apps/marketing/public/blog/early.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
apps/marketing/public/blog/mania-shirt.png
Normal file
|
After Width: | Height: | Size: 839 KiB |
BIN
apps/marketing/public/blog/mm.png
Normal file
|
After Width: | Height: | Size: 862 KiB |
BIN
apps/marketing/public/blog/shop.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
@ -39,7 +39,7 @@ export default function ContentPage({ params }: { params: { content: string } })
|
|||||||
const MDXContent = useMDXComponent(post.body.code);
|
const MDXContent = useMDXComponent(post.body.code);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="prose prose-slate mx-auto">
|
<article className="prose dark:prose-invert mx-auto">
|
||||||
<MDXContent components={mdxComponents} />
|
<MDXContent components={mdxComponents} />
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -39,21 +39,21 @@ export default function BlogPostPage({ params }: { params: { post: string } }) {
|
|||||||
const MDXContent = useMDXComponent(post.body.code);
|
const MDXContent = useMDXComponent(post.body.code);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="prose prose-slate mx-auto py-8">
|
<article className="prose dark:prose-invert mx-auto py-8">
|
||||||
<div className="mb-6 text-center">
|
<div className="mb-6 text-center">
|
||||||
<time dateTime={post.date} className="mb-1 text-xs text-gray-600">
|
<time dateTime={post.date} className="text-muted-foreground mb-1 text-xs">
|
||||||
{new Date(post.date).toLocaleDateString()}
|
{new Date(post.date).toLocaleDateString()}
|
||||||
</time>
|
</time>
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold">{post.title}</h1>
|
<h1 className="text-3xl font-bold">{post.title}</h1>
|
||||||
|
|
||||||
<div className="not-prose relative -mt-2 flex items-center gap-x-4 border-b border-t py-4">
|
<div className="not-prose relative -mt-2 flex items-center gap-x-4 border-b border-t py-4">
|
||||||
<div className="h-10 w-10 rounded-full bg-gray-50">
|
<div className="bg-foreground h-10 w-10 rounded-full">
|
||||||
{post.authorImage && (
|
{post.authorImage && (
|
||||||
<img
|
<img
|
||||||
src={post.authorImage}
|
src={post.authorImage}
|
||||||
alt={`Image of ${post.authorName}`}
|
alt={`Image of ${post.authorName}`}
|
||||||
className="h-10 w-10 rounded-full bg-gray-50"
|
className="bg-foreground/10 h-10 w-10 rounded-full"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,13 +13,13 @@ export default function BlogPage() {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-3xl font-bold lg:text-5xl">From the blog</h1>
|
<h1 className="text-3xl font-bold lg:text-5xl">From the blog</h1>
|
||||||
|
|
||||||
<p className="mx-auto mt-4 max-w-xl text-center text-lg leading-normal text-[#31373D]">
|
<p className="text-muted-foreground mx-auto mt-4 max-w-xl text-center text-lg leading-normal">
|
||||||
Get the latest news from Documenso, including product updates, team announcements and
|
Get the latest news from Documenso, including product updates, team announcements and
|
||||||
more!
|
more!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 divide-y divide-slate-100 border-t border-slate-200 ">
|
<div className="divide-muted-foreground/20 border-muted-foreground/20 mt-10 divide-y border-t">
|
||||||
{blogPosts.map((post, i) => (
|
{blogPosts.map((post, i) => (
|
||||||
<article
|
<article
|
||||||
key={`blog-${i}`}
|
key={`blog-${i}`}
|
||||||
@ -57,12 +57,12 @@ export default function BlogPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mt-4 flex items-center gap-x-4">
|
<div className="relative mt-4 flex items-center gap-x-4">
|
||||||
<div className="h-10 w-10 rounded-full bg-slate-50">
|
<div className="bg-foreground/5 h-10 w-10 rounded-full">
|
||||||
{post.authorImage && (
|
{post.authorImage && (
|
||||||
<img
|
<img
|
||||||
src={post.authorImage}
|
src={post.authorImage}
|
||||||
alt={`Image of ${post.authorName}`}
|
alt={`Image of ${post.authorName}`}
|
||||||
className="h-10 w-10 rounded-full bg-slate-50"
|
className="bg-foreground/5 h-10 w-10 rounded-full"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -58,40 +58,40 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<h1 className="text-3xl font-bold text-slate-900 md:text-4xl">
|
<h1 className="text-foreground text-3xl font-bold md:text-4xl">
|
||||||
Welcome to the <span className="text-primary">open signing</span> revolution{' '}
|
Welcome to the <span className="text-primary">open signing</span> revolution{' '}
|
||||||
<u>{user.name}</u>
|
<u>{user.name}</u>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mt-4 max-w-prose text-base text-slate-500 md:text-lg">
|
<p className="text-muted-foreground mt-4 max-w-prose text-base md:text-lg">
|
||||||
It's not every day you get to be part of a revolution.
|
It's not every day you get to be part of a revolution.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-4 max-w-prose text-base text-slate-500 md:text-lg">
|
<p className="text-muted-foreground mt-4 max-w-prose text-base md:text-lg">
|
||||||
But today is that day, by signing up to Documenso, you're joining a movement of people who
|
But today is that day, by signing up to Documenso, you're joining a movement of people who
|
||||||
want to make the world a better place.
|
want to make the world a better place.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-4 max-w-prose text-base text-slate-500 md:text-lg">
|
<p className="text-muted-foreground mt-4 max-w-prose text-base md:text-lg">
|
||||||
We're going to change the way people sign documents. We're going to make it easier, faster,
|
We're going to change the way people sign documents. We're going to make it easier, faster,
|
||||||
and more secure. And we're going to do it together.
|
and more secure. And we're going to do it together.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<h2 className="text-2xl font-bold text-slate-900">Let's do it together</h2>
|
<h2 className="text-foreground text-2xl font-bold">Let's do it together</h2>
|
||||||
|
|
||||||
<div className="-mx-4 mt-8 flex md:-mx-8">
|
<div className="-mx-4 mt-8 flex md:-mx-8">
|
||||||
<div className="flex flex-1 flex-col justify-end gap-y-4 border-r px-4 last:border-r-0 md:px-8 lg:flex-none">
|
<div className="flex flex-1 flex-col justify-end gap-y-4 border-r px-4 last:border-r-0 md:px-8 lg:flex-none">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-4xl font-semibold text-slate-900 md:text-5xl',
|
'text-foreground text-4xl font-semibold md:text-5xl',
|
||||||
fontCaveat.className,
|
fontCaveat.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Timur
|
Timur
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-sm text-slate-500 md:text-lg">
|
<p className="text-muted-foreground text-sm md:text-lg">
|
||||||
Timur Ercan
|
Timur Ercan
|
||||||
<span className="block lg:hidden" />
|
<span className="block lg:hidden" />
|
||||||
<span className="hidden lg:inline"> - </span>
|
<span className="hidden lg:inline"> - </span>
|
||||||
@ -102,14 +102,14 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
<div className="flex flex-1 flex-col justify-end gap-y-4 border-r px-4 last:border-r-0 md:px-8 lg:flex-none">
|
<div className="flex flex-1 flex-col justify-end gap-y-4 border-r px-4 last:border-r-0 md:px-8 lg:flex-none">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-4xl font-semibold text-slate-900 md:text-5xl',
|
'text-foreground text-4xl font-semibold md:text-5xl',
|
||||||
fontCaveat.className,
|
fontCaveat.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Lucas
|
Lucas
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-sm text-slate-500 md:text-lg">
|
<p className="text-muted-foreground text-sm md:text-lg">
|
||||||
Lucas Smith
|
Lucas Smith
|
||||||
<span className="block lg:hidden" />
|
<span className="block lg:hidden" />
|
||||||
<span className="hidden lg:inline"> - </span>
|
<span className="hidden lg:inline"> - </span>
|
||||||
@ -119,12 +119,16 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
|
|
||||||
<div className="flex flex-1 flex-col justify-end gap-y-4 border-r px-4 last:border-r-0 md:px-8 lg:flex-none">
|
<div className="flex flex-1 flex-col justify-end gap-y-4 border-r px-4 last:border-r-0 md:px-8 lg:flex-none">
|
||||||
{signatureDataUrl && (
|
{signatureDataUrl && (
|
||||||
<img src={signatureDataUrl} alt="your-signature" className="max-w-[172px]" />
|
<img
|
||||||
|
src={signatureDataUrl}
|
||||||
|
alt="your-signature"
|
||||||
|
className="max-w-[172px] dark:invert"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{!signatureDataUrl && (
|
{!signatureDataUrl && (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-4xl font-semibold text-slate-900 md:text-5xl',
|
'text-foreground text-4xl font-semibold md:text-5xl',
|
||||||
fontCaveat.className,
|
fontCaveat.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -132,7 +136,7 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-sm text-slate-500 md:text-lg">
|
<p className="text-muted-foreground text-sm md:text-lg">
|
||||||
{user.name}
|
{user.name}
|
||||||
<span className="block lg:hidden" />
|
<span className="block lg:hidden" />
|
||||||
<span className="hidden lg:inline"> - </span>
|
<span className="hidden lg:inline"> - </span>
|
||||||
@ -143,20 +147,20 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<h2 className="text-2xl font-bold text-slate-900">Your sign in details</h2>
|
<h2 className="text-foreground text-2xl font-bold">Your sign in details</h2>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="text-lg text-slate-500">
|
<p className="text-muted-foreground text-lg">
|
||||||
<span className="font-bold">Email:</span> {user.email}
|
<span className="font-bold">Email:</span> {user.email}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-2 text-lg text-slate-500">
|
<p className="text-muted-foreground mt-2 text-lg">
|
||||||
<span className="font-bold">Password:</span>{' '}
|
<span className="font-bold">Password:</span>{' '}
|
||||||
<PasswordReveal password={password ?? 'password'} />
|
<PasswordReveal password={password ?? 'password'} />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 text-sm italic text-slate-500">
|
<p className="text-muted-foreground mt-4 text-sm italic">
|
||||||
This is a temporary password. Please change it as soon as possible.
|
This is a temporary password. Please change it as soon as possible.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import React from 'react';
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
import { Footer } from '~/components/(marketing)/footer';
|
import { Footer } from '~/components/(marketing)/footer';
|
||||||
import { Header } from '~/components/(marketing)/header';
|
import { Header } from '~/components/(marketing)/header';
|
||||||
@ -8,15 +12,31 @@ export type MarketingLayoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||||
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
setScrollY(window.scrollY);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScroll);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative max-w-[100vw] overflow-y-auto overflow-x-hidden pt-20 md:pt-28">
|
<div className="relative max-w-[100vw] overflow-y-auto overflow-x-hidden pt-20 md:pt-28">
|
||||||
<div className="fixed left-0 top-0 z-50 w-full bg-white/50 backdrop-blur-md">
|
<div
|
||||||
|
className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', {
|
||||||
|
'bg-background/50 backdrop-blur-md': scrollY > 5,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
|
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mx-auto max-w-screen-xl px-4 lg:px-8">{children}</div>
|
<div className="relative mx-auto max-w-screen-xl px-4 lg:px-8">{children}</div>
|
||||||
|
|
||||||
<Footer className="mt-24 bg-transparent backdrop-blur-[2px]" />
|
<Footer className="bg-background border-muted mt-24 border-t" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,26 +7,25 @@ import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recha
|
|||||||
import { formatMonth } from '@documenso/lib/client-only/format-month';
|
import { formatMonth } from '@documenso/lib/client-only/format-month';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
import { StargazersType } from './page';
|
export type BarMetricProps<T extends Record<string, unknown>> = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
data: T;
|
||||||
export type MetricsDataKey = 'stars' | 'forks' | 'mergedPRs' | 'openIssues';
|
metricKey: keyof T[string];
|
||||||
export type GithubMetricProps = HTMLAttributes<HTMLDivElement> & {
|
|
||||||
data: StargazersType;
|
|
||||||
metricKey: MetricsDataKey;
|
|
||||||
title: string;
|
title: string;
|
||||||
label: string;
|
label: string;
|
||||||
chartHeight?: number;
|
chartHeight?: number;
|
||||||
|
extraInfo?: JSX.Element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GithubMetric = ({
|
export const BarMetric = <T extends Record<string, Record<keyof T[string], unknown>>>({
|
||||||
className,
|
className,
|
||||||
data,
|
data,
|
||||||
metricKey,
|
metricKey,
|
||||||
title,
|
title,
|
||||||
label,
|
label,
|
||||||
chartHeight = 400,
|
chartHeight = 400,
|
||||||
|
extraInfo,
|
||||||
...props
|
...props
|
||||||
}: GithubMetricProps) => {
|
}: BarMetricProps<T>) => {
|
||||||
const formattedData = Object.keys(data)
|
const formattedData = Object.keys(data)
|
||||||
.map((key) => ({
|
.map((key) => ({
|
||||||
month: formatMonth(key),
|
month: formatMonth(key),
|
||||||
@ -36,7 +35,10 @@ export const GithubMetric = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)} {...props}>
|
<div className={cn('flex flex-col', className)} {...props}>
|
||||||
<h3 className="px-4 text-lg font-semibold">{title}</h3>
|
<div className="flex items-center px-4">
|
||||||
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
|
<span>{extraInfo}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border pr-2 shadow-sm hover:shadow">
|
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border pr-2 shadow-sm hover:shadow">
|
||||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||||
@ -50,7 +52,7 @@ export const GithubMetric = ({
|
|||||||
formatter={(value) => [Number(value), label]}
|
formatter={(value) => [Number(value), label]}
|
||||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey={metricKey} fill="hsl(var(--primary))" label={label} />
|
<Bar dataKey={metricKey as string} fill="hsl(var(--primary))" label={label} />{' '}
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@ -7,14 +7,14 @@ import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recha
|
|||||||
import { formatMonth } from '@documenso/lib/client-only/format-month';
|
import { formatMonth } from '@documenso/lib/client-only/format-month';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
import { FUNDING_RAISED } from '~/app/(marketing)/open/data';
|
export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
data: Record<string, string | number>[];
|
||||||
|
};
|
||||||
|
|
||||||
export type FundingRaisedProps = HTMLAttributes<HTMLDivElement>;
|
export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => {
|
||||||
|
const formattedData = data.map((item) => ({
|
||||||
export const FundingRaised = ({ className, ...props }: FundingRaisedProps) => {
|
|
||||||
const formattedData = FUNDING_RAISED.map((item) => ({
|
|
||||||
amount: Number(item.amount),
|
amount: Number(item.amount),
|
||||||
date: formatMonth(item.date),
|
date: formatMonth(item.date as string),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { FUNDING_RAISED } from '~/app/(marketing)/open/data';
|
||||||
import { MetricCard } from '~/app/(marketing)/open/metric-card';
|
import { MetricCard } from '~/app/(marketing)/open/metric-card';
|
||||||
import { SalaryBands } from '~/app/(marketing)/open/salary-bands';
|
import { SalaryBands } from '~/app/(marketing)/open/salary-bands';
|
||||||
|
|
||||||
|
import { BarMetric } from './bar-metrics';
|
||||||
import { CapTable } from './cap-table';
|
import { CapTable } from './cap-table';
|
||||||
import { FundingRaised } from './funding-raised';
|
import { FundingRaised } from './funding-raised';
|
||||||
import { GithubMetric } from './gh-metrics';
|
|
||||||
import { TeamMembers } from './team-members';
|
import { TeamMembers } from './team-members';
|
||||||
|
import { OpenPageTooltip } from './tooltip';
|
||||||
|
|
||||||
export const revalidate = 3600;
|
export const revalidate = 3600;
|
||||||
|
|
||||||
@ -29,9 +31,16 @@ const ZStargazersLiveResponse = z.record(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export type StargazersType = z.infer<typeof ZStargazersLiveResponse>;
|
const ZEarlyAdoptersResponse = z.record(
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
time: z.string().datetime(),
|
||||||
|
earlyAdopters: z.number(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// const ZOpenPullRequestsResponse = ZMergedPullRequestsResponse;
|
export type StargazersType = z.infer<typeof ZStargazersLiveResponse>;
|
||||||
|
export type EarlyAdoptersType = z.infer<typeof ZEarlyAdoptersResponse>;
|
||||||
|
|
||||||
export default async function OpenPage() {
|
export default async function OpenPage() {
|
||||||
const {
|
const {
|
||||||
@ -65,8 +74,16 @@ export default async function OpenPage() {
|
|||||||
.then(async (res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZStargazersLiveResponse.parse(res));
|
.then((res) => ZStargazersLiveResponse.parse(res));
|
||||||
|
|
||||||
|
const EARLY_ADOPTERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(async (res) => res.json())
|
||||||
|
.then((res) => ZEarlyAdoptersResponse.parse(res));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto mt-12 max-w-screen-lg">
|
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
|
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
|
||||||
|
|
||||||
@ -107,10 +124,20 @@ export default async function OpenPage() {
|
|||||||
|
|
||||||
<SalaryBands className="col-span-12 lg:col-span-6" />
|
<SalaryBands className="col-span-12 lg:col-span-6" />
|
||||||
|
|
||||||
<FundingRaised className="col-span-12 lg:col-span-6" />
|
<FundingRaised data={FUNDING_RAISED} className="col-span-12 lg:col-span-6" />
|
||||||
|
|
||||||
<CapTable className="col-span-12 lg:col-span-6" />
|
<CapTable className="col-span-12 lg:col-span-6" />
|
||||||
<GithubMetric
|
|
||||||
|
<BarMetric<EarlyAdoptersType>
|
||||||
|
data={EARLY_ADOPTERS_DATA}
|
||||||
|
metricKey="earlyAdopters"
|
||||||
|
title="Early Adopters"
|
||||||
|
label="Early Adopters"
|
||||||
|
className="col-span-12 lg:col-span-6"
|
||||||
|
extraInfo={<OpenPageTooltip />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BarMetric<StargazersType>
|
||||||
data={STARGAZERS_DATA}
|
data={STARGAZERS_DATA}
|
||||||
metricKey="stars"
|
metricKey="stars"
|
||||||
title="Github: Total Stars"
|
title="Github: Total Stars"
|
||||||
@ -118,29 +145,31 @@ export default async function OpenPage() {
|
|||||||
className="col-span-12 lg:col-span-6"
|
className="col-span-12 lg:col-span-6"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GithubMetric
|
<BarMetric<StargazersType>
|
||||||
data={STARGAZERS_DATA}
|
data={STARGAZERS_DATA}
|
||||||
metricKey="mergedPRs"
|
metricKey="mergedPRs"
|
||||||
title="Github: Total Merged PRs"
|
title="Github: Total Merged PRs"
|
||||||
label="Merged PRs"
|
label="Merged PRs"
|
||||||
chartHeight={300}
|
chartHeight={300}
|
||||||
className="col-span-12 lg:col-span-4"
|
className="col-span-12 lg:col-span-6"
|
||||||
/>
|
/>
|
||||||
<GithubMetric
|
|
||||||
|
<BarMetric<StargazersType>
|
||||||
data={STARGAZERS_DATA}
|
data={STARGAZERS_DATA}
|
||||||
metricKey="forks"
|
metricKey="forks"
|
||||||
title="Github: Total Forks"
|
title="Github: Total Forks"
|
||||||
label="Forks"
|
label="Forks"
|
||||||
chartHeight={300}
|
chartHeight={300}
|
||||||
className="col-span-12 lg:col-span-4"
|
className="col-span-12 lg:col-span-6"
|
||||||
/>
|
/>
|
||||||
<GithubMetric
|
|
||||||
|
<BarMetric<StargazersType>
|
||||||
data={STARGAZERS_DATA}
|
data={STARGAZERS_DATA}
|
||||||
metricKey="openIssues"
|
metricKey="openIssues"
|
||||||
title="Github: Total Open Issues"
|
title="Github: Total Open Issues"
|
||||||
label="Open Issues"
|
label="Open Issues"
|
||||||
chartHeight={300}
|
chartHeight={300}
|
||||||
className="col-span-12 lg:col-span-4"
|
className="col-span-12 lg:col-span-6"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
|
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
|
||||||
|
|||||||
40
apps/marketing/src/app/(marketing)/open/tooltip.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
|
export function OpenPageTooltip() {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
className="ml-2 mt-2.5"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M7.49991 0.876892C3.84222 0.876892 0.877075 3.84204 0.877075 7.49972C0.877075 11.1574 3.84222 14.1226 7.49991 14.1226C11.1576 14.1226 14.1227 11.1574 14.1227 7.49972C14.1227 3.84204 11.1576 0.876892 7.49991 0.876892ZM1.82707 7.49972C1.82707 4.36671 4.36689 1.82689 7.49991 1.82689C10.6329 1.82689 13.1727 4.36671 13.1727 7.49972C13.1727 10.6327 10.6329 13.1726 7.49991 13.1726C4.36689 13.1726 1.82707 10.6327 1.82707 7.49972ZM8.24992 4.49999C8.24992 4.9142 7.91413 5.24999 7.49992 5.24999C7.08571 5.24999 6.74992 4.9142 6.74992 4.49999C6.74992 4.08577 7.08571 3.74999 7.49992 3.74999C7.91413 3.74999 8.24992 4.08577 8.24992 4.49999ZM6.00003 5.99999H6.50003H7.50003C7.77618 5.99999 8.00003 6.22384 8.00003 6.49999V9.99999H8.50003H9.00003V11H8.50003H7.50003H6.50003H6.00003V9.99999H6.50003H7.00003V6.99999H6.50003H6.00003V5.99999Z"
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
August and earlier: Active subscribers. September and beyond: Numbers of active
|
||||||
|
subscriptions.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
apps/marketing/src/app/(marketing)/oss-friends/container.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Variants, motion } from 'framer-motion';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
|
import { TOSSFriendsSchema } from './schema';
|
||||||
|
|
||||||
|
const ContainerVariants: Variants = {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.075,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CardVariants: Variants = {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
y: 50,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const randomDegrees = () => {
|
||||||
|
const degrees = [45, 120, -140, -45];
|
||||||
|
|
||||||
|
return degrees[Math.floor(Math.random() * degrees.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OSSFriendsContainerProps = {
|
||||||
|
className?: string;
|
||||||
|
ossFriends: TOSSFriendsSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OSSFriendsContainer = ({ className, ossFriends }: OSSFriendsContainerProps) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={cn('grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3', className)}
|
||||||
|
variants={ContainerVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
>
|
||||||
|
{ossFriends.map((friend, index) => (
|
||||||
|
<motion.div key={index} className="h-full w-full" variants={CardVariants}>
|
||||||
|
<Card
|
||||||
|
className="h-full"
|
||||||
|
degrees={randomDegrees()}
|
||||||
|
gradient={index % 2 === 0}
|
||||||
|
spotlight={index % 2 !== 0}
|
||||||
|
>
|
||||||
|
<CardContent className="flex h-full flex-col p-6">
|
||||||
|
<CardTitle>
|
||||||
|
<Link href={friend.href}>{friend.name}</Link>
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<p className="text-foreground mt-4 flex-1 text-sm">{friend.description}</p>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Link target="_blank" href={friend.href}>
|
||||||
|
<Button>Learn more</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,152 +1,23 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Variants, motion } from 'framer-motion';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
import backgroundPattern from '~/assets/background-pattern.png';
|
||||||
|
|
||||||
const OSSFriends = [
|
import { OSSFriendsContainer } from './container';
|
||||||
{
|
import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema';
|
||||||
name: 'BoxyHQ',
|
|
||||||
description:
|
|
||||||
'BoxyHQ’s suite of APIs for security and privacy helps engineering teams build and ship compliant cloud applications faster.',
|
|
||||||
href: 'https://boxyhq.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cal.com',
|
|
||||||
description:
|
|
||||||
'Cal.com is a scheduling tool that helps you schedule meetings without the back-and-forth emails.',
|
|
||||||
href: 'https://cal.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Crowd.dev',
|
|
||||||
description:
|
|
||||||
'Centralize community, product, and customer data to understand which companies are engaging with your open source project.',
|
|
||||||
href: 'https://www.crowd.dev',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Documenso',
|
|
||||||
description:
|
|
||||||
'The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.',
|
|
||||||
href: 'https://documenso.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Erxes',
|
|
||||||
description:
|
|
||||||
'The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences that work for all types of business.',
|
|
||||||
href: 'https://erxes.io',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Formbricks',
|
|
||||||
description:
|
|
||||||
'Survey granular user segments at any point in the user journey. Gather up to 6x more insights with targeted micro-surveys. All open-source.',
|
|
||||||
href: 'https://formbricks.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Forward Email',
|
|
||||||
description:
|
|
||||||
'Free email forwarding for custom domains. For 6 years and counting, we are the go-to email service for thousands of creators, developers, and businesses.',
|
|
||||||
href: 'https://forwardemail.net',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'GitWonk',
|
|
||||||
description:
|
|
||||||
'GitWonk is an open-source technical documentation tool, designed and built focusing on the developer experience.',
|
|
||||||
href: 'https://gitwonk.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Hanko',
|
|
||||||
description:
|
|
||||||
'Open-source authentication and user management for the passkey era. Integrated in minutes, for web and mobile apps.',
|
|
||||||
href: 'https://www.hanko.io',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'HTMX',
|
|
||||||
description:
|
|
||||||
'HTMX is a dependency-free JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML.',
|
|
||||||
href: 'https://htmx.org',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Infisical',
|
|
||||||
description:
|
|
||||||
'Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.',
|
|
||||||
href: 'https://infisical.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Novu',
|
|
||||||
description:
|
|
||||||
'The open-source notification infrastructure for developers. Simple components and APIs for managing all communication channels in one place.',
|
|
||||||
href: 'https://novu.co',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'OpenBB',
|
|
||||||
description:
|
|
||||||
'Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.',
|
|
||||||
href: 'https://openbb.co',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Sniffnet',
|
|
||||||
description:
|
|
||||||
'Sniffnet is a network monitoring tool to help you easily keep track of your Internet traffic.',
|
|
||||||
href: 'https://www.sniffnet.net',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Typebot',
|
|
||||||
description:
|
|
||||||
'Typebot gives you powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.',
|
|
||||||
href: 'https://typebot.io',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Webiny',
|
|
||||||
description:
|
|
||||||
'Open-source enterprise-grade serverless CMS. Own your data. Scale effortlessly. Customize everything.',
|
|
||||||
href: 'https://www.webiny.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Webstudio',
|
|
||||||
description: 'Webstudio is an open source alternative to Webflow',
|
|
||||||
href: 'https://webstudio.is',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ContainerVariants: Variants = {
|
export default async function OSSFriendsPage() {
|
||||||
initial: {
|
const ossFriends: TOSSFriendsSchema = await fetch('https://formbricks.com/api/oss-friends', {
|
||||||
opacity: 0,
|
next: {
|
||||||
},
|
revalidate: 3600,
|
||||||
animate: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.075,
|
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
};
|
.then(async (res) => res.json())
|
||||||
|
.then(async (data) => z.object({ data: ZOSSFriendsSchema }).parseAsync(data))
|
||||||
|
.then(({ data }) => data)
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
const CardVariants: Variants = {
|
|
||||||
initial: {
|
|
||||||
opacity: 0,
|
|
||||||
y: 50,
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const randomDegrees = () => {
|
|
||||||
const degrees = [45, 120, -140, -45];
|
|
||||||
|
|
||||||
return degrees[Math.floor(Math.random() * degrees.length)];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OSSFriendsPage() {
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mt-12">
|
<div className="relative mt-12">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@ -154,49 +25,19 @@ export default function OSSFriendsPage() {
|
|||||||
Our <span title="Open Source Software">OSS</span> Friends
|
Our <span title="Open Source Software">OSS</span> Friends
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mx-auto mt-4 max-w-[55ch] text-lg leading-normal text-[#31373D]">
|
<p className="text-foreground mx-auto mt-4 max-w-[55ch] text-lg leading-normal">
|
||||||
We love open source and so should you, below you can find a list of our friends who are
|
We love open source and so should you, below you can find a list of our friends who are
|
||||||
just as passionate about open source as we are.
|
just as passionate about open source as we are.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<OSSFriendsContainer className="mt-12" ossFriends={ossFriends} />
|
||||||
className="mt-12 grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3"
|
|
||||||
variants={ContainerVariants}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
>
|
|
||||||
{OSSFriends.map((friend, index) => (
|
|
||||||
<motion.div key={index} className="h-full w-full" variants={CardVariants}>
|
|
||||||
<Card
|
|
||||||
className="h-full"
|
|
||||||
degrees={randomDegrees()}
|
|
||||||
gradient={index % 2 === 0}
|
|
||||||
spotlight={index % 2 !== 0}
|
|
||||||
>
|
|
||||||
<CardContent className="flex h-full flex-col p-6">
|
|
||||||
<CardTitle>
|
|
||||||
<Link href={friend.href}>{friend.name}</Link>
|
|
||||||
</CardTitle>
|
|
||||||
|
|
||||||
<p className="mt-4 flex-1 text-sm text-slate-700">{friend.description}</p>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
<Link target="_blank" href={friend.href}>
|
|
||||||
<Button>Learn more</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 -z-10 flex items-start justify-center">
|
<div className="absolute inset-0 -z-10 flex items-start justify-center">
|
||||||
<Image
|
<Image
|
||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="-mr-[15vw] -mt-[15vh] h-full max-h-[150vh] scale-125 object-cover md:-mr-[50vw] md:scale-150 lg:scale-[175%]"
|
className="-mr-[15vw] -mt-[15vh] h-full max-h-[150vh] scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:-mr-[50vw] md:scale-150 lg:scale-[175%]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
11
apps/marketing/src/app/(marketing)/oss-friends/schema.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZOSSFriendsSchema = z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
href: z.string().url(),
|
||||||
|
description: z.string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TOSSFriendsSchema = z.infer<typeof ZOSSFriendsSchema>;
|
||||||
@ -20,14 +20,14 @@ export type PricingPageProps = {
|
|||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="mt-12">
|
<div className="mt-6 sm:mt-12">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-3xl font-bold lg:text-5xl">Pricing</h1>
|
<h1 className="text-3xl font-bold lg:text-5xl">Pricing</h1>
|
||||||
|
|
||||||
<p className="mt-4 text-lg leading-normal text-[#31373D]">
|
<p className="text-foreground mt-4 text-lg leading-normal">
|
||||||
Designed for every stage of your journey.
|
Designed for every stage of your journey.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg leading-normal text-[#31373D]">Get started today.</p>
|
<p className="text-foreground text-lg leading-normal">Get started today.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
@ -45,12 +45,12 @@ export default function PricingPage() {
|
|||||||
What is the difference between the plans?
|
What is the difference between the plans?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
|
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||||
You can self-host Documenso for free or use our ready-to-use hosted version. The
|
You can self-host Documenso for free or use our ready-to-use hosted version. The
|
||||||
hosted version comes with additional support, painless scalability and more. Early
|
hosted version comes with additional support, painless scalability and more. Early
|
||||||
adopters of the community plan will get access to all features we build this year, for
|
adopters will get access to all features we build this year, for no additional cost!
|
||||||
no additional cost! Forever! Yes, that includes multiple users per account later. If
|
Forever! Yes, that includes multiple users per account later. If you want Documenso
|
||||||
you want Documenso for your enterprise, we are happy to talk about your needs.
|
for your enterprise, we are happy to talk about your needs.
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
@ -59,7 +59,7 @@ export default function PricingPage() {
|
|||||||
How do you handle my data?
|
How do you handle my data?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
|
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||||
Securely. Our data centers are located in Frankfurt (Germany), giving us the best
|
Securely. Our data centers are located in Frankfurt (Germany), giving us the best
|
||||||
local privacy laws. We are very aware of the sensitive nature of our data and follow
|
local privacy laws. We are very aware of the sensitive nature of our data and follow
|
||||||
best practices to ensure the security and integrity of the data entrusted to us.
|
best practices to ensure the security and integrity of the data entrusted to us.
|
||||||
@ -71,7 +71,7 @@ export default function PricingPage() {
|
|||||||
Why should I use your hosting service?
|
Why should I use your hosting service?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
|
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||||
Using our hosted version is the easiest way to get started, you can simply subscribe
|
Using our hosted version is the easiest way to get started, you can simply subscribe
|
||||||
and start signing your documents. We take care of the infrastructure, so you can focus
|
and start signing your documents. We take care of the infrastructure, so you can focus
|
||||||
on your business. Additionally, when using our hosted version you benefit from our
|
on your business. Additionally, when using our hosted version you benefit from our
|
||||||
@ -84,7 +84,7 @@ export default function PricingPage() {
|
|||||||
How can I contribute?
|
How can I contribute?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
|
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||||
That's awesome. You can take a look at the current{' '}
|
That's awesome. You can take a look at the current{' '}
|
||||||
<Link
|
<Link
|
||||||
className="text-documenso-700 font-bold"
|
className="text-documenso-700 font-bold"
|
||||||
@ -111,7 +111,7 @@ export default function PricingPage() {
|
|||||||
Can I use Documenso commercially?
|
Can I use Documenso commercially?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
|
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||||
Yes! Documenso is offered under the GNU AGPL V3 open source license. This means you
|
Yes! Documenso is offered under the GNU AGPL V3 open source license. This means you
|
||||||
can use it for free and even modify it to fit your needs, as long as you publish your
|
can use it for free and even modify it to fit your needs, as long as you publish your
|
||||||
changes under the same license.
|
changes under the same license.
|
||||||
@ -123,7 +123,7 @@ export default function PricingPage() {
|
|||||||
Why should I prefer Documenso over DocuSign or some other signing tool?
|
Why should I prefer Documenso over DocuSign or some other signing tool?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
|
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||||
Documenso is a community effort to create an open and vibrant ecosystem around a tool,
|
Documenso is a community effort to create an open and vibrant ecosystem around a tool,
|
||||||
everybody is free to use and adapt. By being truly open we want to create trusted
|
everybody is free to use and adapt. By being truly open we want to create trusted
|
||||||
infrastructure for the future of the internet.
|
infrastructure for the future of the internet.
|
||||||
@ -135,7 +135,7 @@ export default function PricingPage() {
|
|||||||
Where can I get support?
|
Where can I get support?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="max-w-prose text-sm leading-relaxed text-slate-500">
|
<AccordionContent className="text-muted-foreground max-w-prose text-sm leading-relaxed">
|
||||||
We are happy to assist you at{' '}
|
We are happy to assist you at{' '}
|
||||||
<Link
|
<Link
|
||||||
className="text-documenso-700 font-bold"
|
className="text-documenso-700 font-bold"
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getDocumentAndRecipientByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { SinglePlayerModeSuccess } from '~/components/(marketing)/single-player-mode/single-player-mode-success';
|
||||||
|
|
||||||
|
export type SinglePlayerModeSuccessPageProps = {
|
||||||
|
params: {
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SinglePlayerModeSuccessPage({
|
||||||
|
params: { token },
|
||||||
|
}: SinglePlayerModeSuccessPageProps) {
|
||||||
|
if (!token) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await getDocumentAndRecipientByToken({
|
||||||
|
token,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!document || document.status !== DocumentStatus.COMPLETED) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SinglePlayerModeSuccess document={document} />;
|
||||||
|
}
|
||||||
244
apps/marketing/src/app/(marketing)/single-player-mode/page.tsx
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { Field, Prisma, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
|
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
|
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
||||||
|
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
||||||
|
import {
|
||||||
|
DocumentFlowFormContainer,
|
||||||
|
DocumentFlowFormContainerHeader,
|
||||||
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
|
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
|
||||||
|
|
||||||
|
type SinglePlayerModeStep = 'fields' | 'sign';
|
||||||
|
|
||||||
|
export default function SinglePlayerModePage() {
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
|
||||||
|
const [fields, setFields] = useState<Field[]>([]);
|
||||||
|
|
||||||
|
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
||||||
|
fields: {
|
||||||
|
title: 'Add document',
|
||||||
|
description: 'Upload a document and add fields.',
|
||||||
|
stepIndex: 1,
|
||||||
|
onBackStep: uploadedFile
|
||||||
|
? () => {
|
||||||
|
setUploadedFile(null);
|
||||||
|
setFields([]);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onNextStep: () => setStep('sign'),
|
||||||
|
},
|
||||||
|
sign: {
|
||||||
|
title: 'Sign',
|
||||||
|
description: 'Enter your details.',
|
||||||
|
stepIndex: 2,
|
||||||
|
onBackStep: () => setStep('fields'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
analytics.startSessionRecording('marketing_session_recording_spm');
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
analytics.stopSessionRecording();
|
||||||
|
};
|
||||||
|
}, [analytics]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert the selected fields into the local state.
|
||||||
|
*/
|
||||||
|
const onFieldsSubmit = (data: TAddFieldsFormSchema) => {
|
||||||
|
if (!uploadedFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFields(
|
||||||
|
data.fields.map((field, i) => ({
|
||||||
|
id: i,
|
||||||
|
documentId: -1,
|
||||||
|
recipientId: -1,
|
||||||
|
type: field.type,
|
||||||
|
page: field.pageNumber,
|
||||||
|
positionX: new Prisma.Decimal(field.pageX),
|
||||||
|
positionY: new Prisma.Decimal(field.pageY),
|
||||||
|
width: new Prisma.Decimal(field.pageWidth),
|
||||||
|
height: new Prisma.Decimal(field.pageHeight),
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
analytics.capture('Marketing: SPM - Fields added');
|
||||||
|
|
||||||
|
documentFlow.fields.onNextStep?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload, create, sign and send the document.
|
||||||
|
*/
|
||||||
|
const onSignSubmit = async (data: TAddSignatureFormSchema) => {
|
||||||
|
if (!uploadedFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const putFileData = await putFile(uploadedFile.file);
|
||||||
|
|
||||||
|
const documentToken = await createSinglePlayerDocument({
|
||||||
|
documentData: {
|
||||||
|
type: putFileData.type,
|
||||||
|
data: putFileData.data,
|
||||||
|
},
|
||||||
|
documentName: uploadedFile.file.name,
|
||||||
|
signer: data,
|
||||||
|
fields: fields.map((field) => ({
|
||||||
|
page: field.page,
|
||||||
|
type: field.type,
|
||||||
|
positionX: field.positionX.toNumber(),
|
||||||
|
positionY: field.positionY.toNumber(),
|
||||||
|
width: field.width.toNumber(),
|
||||||
|
height: field.height.toNumber(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
analytics.capture('Marketing: SPM - Document signed', {
|
||||||
|
signer: data.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`/single-player-mode/${documentToken}/success`);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholderRecipient: Recipient = {
|
||||||
|
id: -1,
|
||||||
|
documentId: -1,
|
||||||
|
email: '',
|
||||||
|
name: '',
|
||||||
|
token: '',
|
||||||
|
expired: null,
|
||||||
|
signedAt: null,
|
||||||
|
readStatus: 'OPENED',
|
||||||
|
signingStatus: 'NOT_SIGNED',
|
||||||
|
sendStatus: 'NOT_SENT',
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileDrop = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const base64String = base64.encode(new Uint8Array(arrayBuffer));
|
||||||
|
|
||||||
|
setUploadedFile({
|
||||||
|
file,
|
||||||
|
fileBase64: `data:application/pdf;base64,${base64String}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
analytics.capture('Marketing: SPM - Document uploaded');
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 sm:mt-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
|
||||||
|
|
||||||
|
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
|
||||||
|
View our{' '}
|
||||||
|
<Link
|
||||||
|
href={'/pricing'}
|
||||||
|
target="_blank"
|
||||||
|
className="hover:text-foreground/80 font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
community plan
|
||||||
|
</Link>{' '}
|
||||||
|
for exclusive features, including the ability to collaborate with multiple signers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 grid w-full grid-cols-12 gap-8">
|
||||||
|
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||||
|
{uploadedFile ? (
|
||||||
|
<Card gradient>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<LazyPDFViewer document={uploadedFile.fileBase64} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
|
<DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
|
||||||
|
<DocumentFlowFormContainerHeader
|
||||||
|
title={currentDocumentFlow.title}
|
||||||
|
description={currentDocumentFlow.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Add fields to PDF page. */}
|
||||||
|
{step === 'fields' && (
|
||||||
|
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
|
||||||
|
<AddFieldsFormPartial
|
||||||
|
documentFlow={documentFlow.fields}
|
||||||
|
hideRecipients={true}
|
||||||
|
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
|
fields={fields}
|
||||||
|
onSubmit={onFieldsSubmit}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enter user details and signature. */}
|
||||||
|
{step === 'sign' && (
|
||||||
|
<AddSignatureFormPartial
|
||||||
|
documentFlow={documentFlow.sign}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
|
fields={fields}
|
||||||
|
onSubmit={onSignSubmit}
|
||||||
|
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
||||||
|
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DocumentFlowFormContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,20 @@
|
|||||||
import { Inter } from 'next/font/google';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
import { Caveat, Inter } from 'next/font/google';
|
||||||
|
|
||||||
|
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
|
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||||
|
|
||||||
|
import { ThemeProvider } from '~/providers/next-theme';
|
||||||
import { PlausibleProvider } from '~/providers/plausible';
|
import { PlausibleProvider } from '~/providers/plausible';
|
||||||
|
import { PostHogPageview } from '~/providers/posthog';
|
||||||
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||||
|
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||||
@ -32,9 +40,15 @@ export const metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const flags = await getAllAnonymousFlags();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={fontInter.variable} suppressHydrationWarning>
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={cn(fontInter.variable, fontCaveat.variable)}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
<head>
|
<head>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
@ -42,8 +56,17 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
<Suspense>
|
||||||
|
<PostHogPageview />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<PlausibleProvider>{children}</PlausibleProvider>
|
<FeatureFlagProvider initialFlags={flags}>
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
|
<PlausibleProvider>{children}</PlausibleProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</FeatureFlagProvider>
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export default function NotFound() {
|
|||||||
<Image
|
<Image
|
||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%]"
|
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-100 lg:scale-[100%]"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
BIN
apps/marketing/src/assets/signing-celebration.png
Normal file
|
After Width: | Height: | Size: 20 MiB |
@ -40,8 +40,8 @@ export const Callout = ({ starCount }: CalloutProps) => {
|
|||||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
className="rounded-full bg-transparent backdrop-blur-sm"
|
||||||
onClick={onSignUpClick}
|
onClick={onSignUpClick}
|
||||||
>
|
>
|
||||||
Get the Community Plan
|
Get the Early Adopters Plan
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
||||||
$30/mo. forever!
|
$30/mo. forever!
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
@ -55,7 +55,7 @@ export const Callout = ({ starCount }: CalloutProps) => {
|
|||||||
<Github className="mr-2 h-5 w-5" />
|
<Github className="mr-2 h-5 w-5" />
|
||||||
Star on Github
|
Star on Github
|
||||||
{starCount && starCount > 0 && (
|
{starCount && starCount > 0 && (
|
||||||
<span className="bg-primary -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
||||||
{starCount.toLocaleString('en-US')}
|
{starCount.toLocaleString('en-US')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { usePlausible } from 'next-plausible';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
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 {
|
import {
|
||||||
@ -43,9 +44,11 @@ export type ClaimPlanDialogProps = {
|
|||||||
|
|
||||||
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
|
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const { toast } = useToast();
|
const analytics = useAnalytics();
|
||||||
const event = usePlausible();
|
const event = usePlausible();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
|
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -73,10 +76,12 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
event('claim-plan-pricing');
|
event('claim-plan-pricing');
|
||||||
|
analytics.capture('Marketing: Claim plan', { planId, email });
|
||||||
|
|
||||||
window.location.href = redirectUrl;
|
window.location.href = redirectUrl;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
event('claim-plan-failed');
|
event('claim-plan-failed');
|
||||||
|
analytics.capture('Marketing: Claim plan failure', { planId, email });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: 'Something went wrong',
|
||||||
@ -118,7 +123,7 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-slate-500">Name</Label>
|
<Label className="text-muted-foreground">Name</Label>
|
||||||
|
|
||||||
<Input type="text" className="mt-2" {...register('name')} autoFocus />
|
<Input type="text" className="mt-2" {...register('name')} autoFocus />
|
||||||
|
|
||||||
@ -126,7 +131,7 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-slate-500">Email</Label>
|
<Label className="text-muted-foreground">Email</Label>
|
||||||
|
|
||||||
<Input type="email" className="mt-2" {...register('email')} />
|
<Input type="email" className="mt-2" {...register('email')} />
|
||||||
|
|
||||||
@ -134,7 +139,7 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" size="lg" loading={isSubmitting}>
|
<Button type="submit" size="lg" loading={isSubmitting}>
|
||||||
Claim the Community Plan (
|
Claim the early adopters Plan (
|
||||||
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
|
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
|
||||||
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
||||||
? 'Monthly'
|
? 'Monthly'
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import Confetti from 'react-confetti';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size';
|
||||||
|
|
||||||
|
export default function ConfettiScreen({
|
||||||
|
numberOfPieces: numberOfPiecesProp = 200,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof Confetti> & { duration?: number }) {
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
const { width, height } = useWindowSize();
|
||||||
|
|
||||||
|
const [numberOfPieces, setNumberOfPieces] = useState(numberOfPiecesProp);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!props.duration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setNumberOfPieces(0);
|
||||||
|
}, props.duration);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [props.duration]);
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<Confetti
|
||||||
|
{...props}
|
||||||
|
className="w-full"
|
||||||
|
numberOfPieces={numberOfPieces}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -22,7 +22,7 @@ export const FasterSmarterBeautifulBento = ({
|
|||||||
<Image
|
<Image
|
||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
||||||
@ -33,41 +33,53 @@ export const FasterSmarterBeautifulBento = ({
|
|||||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
||||||
<Card className="col-span-2" degrees={45} gradient>
|
<Card className="col-span-2" degrees={45} gradient>
|
||||||
<CardContent className="grid grid-cols-12 gap-8 overflow-hidden p-6 lg:aspect-[2.5/1]">
|
<CardContent className="grid grid-cols-12 gap-8 overflow-hidden p-6 lg:aspect-[2.5/1]">
|
||||||
<p className="col-span-12 leading-relaxed text-[#555E67] lg:col-span-6">
|
<p className="text-foreground/80 col-span-12 leading-relaxed lg:col-span-6">
|
||||||
<strong className="block">Fast.</strong>
|
<strong className="block">Fast.</strong>
|
||||||
When it comes to sending or receiving a contract, you can count on lightning-fast
|
When it comes to sending or receiving a contract, you can count on lightning-fast
|
||||||
speeds.
|
speeds.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
|
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
|
||||||
<Image src={cardFastFigure} alt="its fast" className="max-w-[80%] lg:max-w-none" />
|
<Image
|
||||||
|
src={cardFastFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="max-w-[80%] dark:contrast-[70%] dark:hue-rotate-180 dark:invert lg:max-w-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Beautiful.</strong>
|
<strong className="block">Beautiful.</strong>
|
||||||
Because signing should be celebrated. That’s why we care about the smallest detail in
|
Because signing should be celebrated. That’s why we care about the smallest detail in
|
||||||
our product.
|
our product.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardBeautifulFigure} alt="its fast" className="w-full max-w-xs" />
|
<Image
|
||||||
|
src={cardBeautifulFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-xs dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Smart.</strong>
|
<strong className="block">Smart.</strong>
|
||||||
Our custom templates come with smart rules that can help you save time and energy.
|
Our custom templates come with smart rules that can help you save time and energy.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardSmartFigure} alt="its fast" className="w-full max-w-[16rem]" />
|
<Image
|
||||||
|
src={cardSmartFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-[16rem] dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Github, MessagesSquare, Twitter } from 'lucide-react';
|
import { Github, MessagesSquare, Moon, Sun, Twitter } from 'lucide-react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
@ -17,6 +20,7 @@ const SOCIAL_LINKS = [
|
|||||||
|
|
||||||
const FOOTER_LINKS = [
|
const FOOTER_LINKS = [
|
||||||
{ href: '/pricing', text: 'Pricing' },
|
{ href: '/pricing', text: 'Pricing' },
|
||||||
|
{ href: '/single-player-mode', text: 'Single Player Mode' },
|
||||||
{ href: '/blog', text: 'Blog' },
|
{ href: '/blog', text: 'Blog' },
|
||||||
{ href: '/open', text: 'Open' },
|
{ href: '/open', text: 'Open' },
|
||||||
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
||||||
@ -26,17 +30,30 @@ const FOOTER_LINKS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
export const Footer = ({ className, ...props }: FooterProps) => {
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('border-t py-12', className)} {...props}>
|
<div className={cn('border-t py-12', className)} {...props}>
|
||||||
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
||||||
<div>
|
<div>
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={0}></Image>
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="dark:invert"
|
||||||
|
width={170}
|
||||||
|
height={0}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-4 text-[#8D8D8D]">
|
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-4">
|
||||||
{SOCIAL_LINKS.map((link, index) => (
|
{SOCIAL_LINKS.map((link, index) => (
|
||||||
<Link key={index} href={link.href} target="_blank" className="hover:text-[#6D6D6D]">
|
<Link
|
||||||
|
key={index}
|
||||||
|
href={link.href}
|
||||||
|
target="_blank"
|
||||||
|
className="text-muted-foreground hover:text-muted-foreground/80"
|
||||||
|
>
|
||||||
{link.icon}
|
{link.icon}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@ -49,17 +66,29 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
key={index}
|
key={index}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
target={link.target}
|
target={link.target}
|
||||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 text-sm"
|
||||||
>
|
>
|
||||||
{link.text}
|
{link.text}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto mt-4 w-full max-w-screen-xl px-8 md:mt-12 lg:mt-24">
|
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap justify-between gap-4 px-8 md:mt-12 lg:mt-24">
|
||||||
<p className="text-sm text-[#8D8D8D]">
|
<p className="text-muted-foreground text-sm">
|
||||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
||||||
|
<button type="button" className="text-muted-foreground" onClick={() => setTheme('light')}>
|
||||||
|
<Sun className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Light</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="text-muted-foreground" onClick={() => setTheme('dark')}>
|
||||||
|
<Moon className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Dark</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { HTMLAttributes, useState } from 'react';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
import { HamburgerMenu } from './mobile-hamburger';
|
import { HamburgerMenu } from './mobile-hamburger';
|
||||||
@ -15,29 +16,59 @@ export type HeaderProps = HTMLAttributes<HTMLElement>;
|
|||||||
export const Header = ({ className, ...props }: HeaderProps) => {
|
export const Header = ({ className, ...props }: HeaderProps) => {
|
||||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const isSinglePlayerModeMarketingEnabled = getFlag('marketing_header_single_player_mode');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={cn('flex items-center justify-between', className)} {...props}>
|
<header className={cn('flex items-center justify-between', className)} {...props}>
|
||||||
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
<div className="flex items-center space-x-4">
|
||||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
||||||
</Link>
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="dark:invert"
|
||||||
|
width={170}
|
||||||
|
height={25}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{isSinglePlayerModeMarketingEnabled && (
|
||||||
|
<Link
|
||||||
|
href="/single-player-mode"
|
||||||
|
className="bg-primary dark:text-background rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
|
||||||
|
>
|
||||||
|
Try now!
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="hidden items-center gap-x-6 md:flex">
|
<div className="hidden items-center gap-x-6 md:flex">
|
||||||
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
<Link
|
||||||
|
href="/pricing"
|
||||||
|
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||||
|
>
|
||||||
Pricing
|
Pricing
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/blog" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
<Link
|
||||||
|
href="/blog"
|
||||||
|
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||||
|
>
|
||||||
Blog
|
Blog
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/open" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
<Link
|
||||||
|
href="/open"
|
||||||
|
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||||
|
>
|
||||||
Open
|
Open
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="https://app.documenso.com/login"
|
href="https://app.documenso.com/login"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -6,7 +6,9 @@ import Link from 'next/link';
|
|||||||
import { Variants, motion } from 'framer-motion';
|
import { Variants, motion } from 'framer-motion';
|
||||||
import { Github } from 'lucide-react';
|
import { Github } from 'lucide-react';
|
||||||
import { usePlausible } from 'next-plausible';
|
import { usePlausible } from 'next-plausible';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
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';
|
||||||
|
|
||||||
@ -51,6 +53,10 @@ const HeroTitleVariants: Variants = {
|
|||||||
export const Hero = ({ className, ...props }: HeroProps) => {
|
export const Hero = ({ className, ...props }: HeroProps) => {
|
||||||
const event = usePlausible();
|
const event = usePlausible();
|
||||||
|
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const heroMarketingCTA = getFlag('marketing_landing_hero_cta');
|
||||||
|
|
||||||
const onSignUpClick = () => {
|
const onSignUpClick = () => {
|
||||||
const el = document.getElementById('email');
|
const el = document.getElementById('email');
|
||||||
|
|
||||||
@ -80,7 +86,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
<Image
|
<Image
|
||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="-mr-[50vw] -mt-[15vh] h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
className="-mr-[50vw] -mt-[15vh] h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@ -108,8 +114,8 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
className="rounded-full bg-transparent backdrop-blur-sm"
|
||||||
onClick={onSignUpClick}
|
onClick={onSignUpClick}
|
||||||
>
|
>
|
||||||
Get the Community Plan
|
Get the Early Adopters Plan
|
||||||
<span className="bg-primary -mr-2 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
||||||
$30/mo. forever!
|
$30/mo. forever!
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
@ -122,23 +128,45 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
{match(heroMarketingCTA)
|
||||||
variants={HeroTitleVariants}
|
.with('spm', () => (
|
||||||
initial="initial"
|
<motion.div
|
||||||
animate="animate"
|
variants={HeroTitleVariants}
|
||||||
className="mt-8 flex flex-col items-center justify-center gap-x-6 gap-y-4"
|
initial="initial"
|
||||||
>
|
animate="animate"
|
||||||
<Link
|
className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition duration-300"
|
||||||
href="https://www.producthunt.com/posts/documenso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-documenso"
|
>
|
||||||
target="_blank"
|
<Link href="/single-player-mode" className="block px-4 py-2 text-center">
|
||||||
>
|
<h2 className="text-muted-foreground text-xs font-semibold">
|
||||||
<img
|
Introducing Single Player Mode
|
||||||
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=395047&theme=light&period=daily"
|
</h2>
|
||||||
alt="Documenso - The open source DocuSign alternative | Product Hunt"
|
|
||||||
style={{ width: '250px', height: '54px' }}
|
<h1 className="text-foreground mt-1.5 font-medium leading-5">
|
||||||
/>
|
Self sign for free!
|
||||||
</Link>
|
</h1>
|
||||||
</motion.div>
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
))
|
||||||
|
.with('productHunt', () => (
|
||||||
|
<motion.div
|
||||||
|
variants={HeroTitleVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
className="mt-8 flex flex-col items-center justify-center gap-x-6 gap-y-4"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="https://www.producthunt.com/posts/documenso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-documenso"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=395047&theme=light&period=daily"
|
||||||
|
alt="Documenso - The open source DocuSign alternative | Product Hunt"
|
||||||
|
style={{ width: '250px', height: '54px' }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
))
|
||||||
|
.otherwise(() => null)}
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mt-12"
|
className="mt-12"
|
||||||
|
|||||||
@ -14,6 +14,10 @@ export type MobileNavigationProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MENU_NAVIGATION_LINKS = [
|
export const MENU_NAVIGATION_LINKS = [
|
||||||
|
{
|
||||||
|
href: '/single-player-mode',
|
||||||
|
text: 'Single Player Mode',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/blog',
|
href: '/blog',
|
||||||
text: 'Blog',
|
text: 'Blog',
|
||||||
@ -55,7 +59,13 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||||
<SheetContent className="w-full max-w-[400px]">
|
<SheetContent className="w-full max-w-[400px]">
|
||||||
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
|
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
|
||||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="dark:invert"
|
||||||
|
width={170}
|
||||||
|
height={25}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -85,7 +95,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
className="text-2xl font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
||||||
href={href}
|
href={href}
|
||||||
onClick={() => handleMenuItemClick()}
|
onClick={() => handleMenuItemClick()}
|
||||||
>
|
>
|
||||||
@ -99,7 +109,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
<Link
|
<Link
|
||||||
href="https://twitter.com/documenso"
|
href="https://twitter.com/documenso"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="text-foreground hover:text-foreground/80"
|
||||||
>
|
>
|
||||||
<Twitter className="h-6 w-6" />
|
<Twitter className="h-6 w-6" />
|
||||||
</Link>
|
</Link>
|
||||||
@ -107,7 +117,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
<Link
|
<Link
|
||||||
href="https://github.com/documenso/documenso"
|
href="https://github.com/documenso/documenso"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="text-foreground hover:text-foreground/80"
|
||||||
>
|
>
|
||||||
<Github className="h-6 w-6" />
|
<Github className="h-6 w-6" />
|
||||||
</Link>
|
</Link>
|
||||||
@ -115,7 +125,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
<Link
|
<Link
|
||||||
href="https://documen.so/discord"
|
href="https://documen.so/discord"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
className="text-foreground hover:text-foreground/80"
|
||||||
>
|
>
|
||||||
<MessagesSquare className="h-6 w-6" />
|
<MessagesSquare className="h-6 w-6" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplat
|
|||||||
<Image
|
<Image
|
||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
||||||
@ -30,41 +30,53 @@ export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplat
|
|||||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
||||||
<Card className="col-span-2" degrees={45} gradient>
|
<Card className="col-span-2" degrees={45} gradient>
|
||||||
<CardContent className="grid grid-cols-12 gap-8 overflow-hidden p-6 lg:aspect-[2.5/1]">
|
<CardContent className="grid grid-cols-12 gap-8 overflow-hidden p-6 lg:aspect-[2.5/1]">
|
||||||
<p className="col-span-12 leading-relaxed text-[#555E67] lg:col-span-6">
|
<p className="text-foreground/80 col-span-12 leading-relaxed lg:col-span-6">
|
||||||
<strong className="block">Open Source or Hosted.</strong>
|
<strong className="block">Open Source or Hosted.</strong>
|
||||||
It’s up to you. Either clone our repository or rely on our easy to use hosting
|
It’s up to you. Either clone our repository or rely on our easy to use hosting
|
||||||
solution.
|
solution.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
|
<div className="col-span-12 -my-6 -mr-6 flex items-end justify-end pt-12 lg:col-span-6">
|
||||||
<Image src={cardOpenFigure} alt="its fast" className="max-w-[80%] lg:max-w-full" />
|
<Image
|
||||||
|
src={cardOpenFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="max-w-[80%] dark:contrast-[70%] dark:hue-rotate-180 dark:invert lg:max-w-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Build on top.</strong>
|
<strong className="block">Build on top.</strong>
|
||||||
Make it your own through advanced customization and adjustability.
|
Make it your own through advanced customization and adjustability.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardBuildFigure} alt="its fast" className="w-full max-w-xs" />
|
<Image
|
||||||
|
src={cardBuildFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-xs dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Template Store (Soon).</strong>
|
<strong className="block">Template Store (Soon).</strong>
|
||||||
Choose a template from the community app store. Or submit your own template for others
|
Choose a template from the community app store. Or submit your own template for others
|
||||||
to use.
|
to use.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardTemplateFigure} alt="its fast" className="w-full max-w-sm" />
|
<Image
|
||||||
|
src={cardTemplateFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-sm dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -41,10 +41,13 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.button
|
<motion.button
|
||||||
key="MONTHLY"
|
key="MONTHLY"
|
||||||
className={cn('relative flex items-center gap-x-2.5 px-1 py-2.5 text-[#727272]', {
|
className={cn(
|
||||||
'text-slate-900': period === 'MONTHLY',
|
'text-muted-foreground relative flex items-center gap-x-2.5 px-1 py-2.5',
|
||||||
'hover:text-slate-900/80': period !== 'MONTHLY',
|
{
|
||||||
})}
|
'text-foreground': period === 'MONTHLY',
|
||||||
|
'hover:text-foreground/80': period !== 'MONTHLY',
|
||||||
|
},
|
||||||
|
)}
|
||||||
onClick={() => setPeriod('MONTHLY')}
|
onClick={() => setPeriod('MONTHLY')}
|
||||||
>
|
>
|
||||||
Monthly
|
Monthly
|
||||||
@ -58,14 +61,17 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
key="YEARLY"
|
key="YEARLY"
|
||||||
className={cn('relative flex items-center gap-x-2.5 px-1 py-2.5 text-[#727272]', {
|
className={cn(
|
||||||
'text-slate-900': period === 'YEARLY',
|
'text-muted-foreground relative flex items-center gap-x-2.5 px-1 py-2.5',
|
||||||
'hover:text-slate-900/80': period !== 'YEARLY',
|
{
|
||||||
})}
|
'text-foreground': period === 'YEARLY',
|
||||||
|
'hover:text-foreground/80': period !== 'YEARLY',
|
||||||
|
},
|
||||||
|
)}
|
||||||
onClick={() => setPeriod('YEARLY')}
|
onClick={() => setPeriod('YEARLY')}
|
||||||
>
|
>
|
||||||
Yearly
|
Yearly
|
||||||
<div className="block rounded-full bg-slate-200 px-2 py-0.5 text-xs text-slate-700">
|
<div className="bg-muted text-foreground block rounded-full px-2 py-0.5 text-xs">
|
||||||
Save $60
|
Save $60
|
||||||
</div>
|
</div>
|
||||||
{period === 'YEARLY' && (
|
{period === 'YEARLY' && (
|
||||||
@ -81,12 +87,12 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
<div className="mt-12 grid grid-cols-1 gap-x-6 gap-y-12 md:grid-cols-2 lg:grid-cols-3">
|
<div className="mt-12 grid grid-cols-1 gap-x-6 gap-y-12 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div
|
<div
|
||||||
data-plan="self-hosted"
|
data-plan="self-hosted"
|
||||||
className="flex flex-col items-center justify-center rounded-lg border bg-white px-8 py-12 shadow-lg shadow-slate-900/5"
|
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-4xl font-medium text-slate-900">Self Hosted</p>
|
<p className="text-foreground text-4xl font-medium">Self Hosted</p>
|
||||||
<p className="text-primary mt-2.5 text-xl font-medium">Free</p>
|
<p className="text-primary mt-2.5 text-xl font-medium">Free</p>
|
||||||
|
|
||||||
<p className="mt-4 max-w-[30ch] text-center text-slate-900">
|
<p className="text-foreground mt-4 max-w-[30ch] text-center">
|
||||||
For small teams and individuals who need a simple solution
|
For small teams and individuals who need a simple solution
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -100,20 +106,20 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
<div className="mt-8 flex w-full flex-col divide-y">
|
||||||
<p className="py-4 font-medium text-slate-900">Host your own instance</p>
|
<p className="text-foreground py-4 font-medium">Host your own instance</p>
|
||||||
<p className="py-4 text-slate-900">Full Control</p>
|
<p className="text-foreground py-4">Full Control</p>
|
||||||
<p className="py-4 text-slate-900">Customizability</p>
|
<p className="text-foreground py-4">Customizability</p>
|
||||||
<p className="py-4 text-slate-900">Docker Ready</p>
|
<p className="text-foreground py-4">Docker Ready</p>
|
||||||
<p className="py-4 text-slate-900">Community Support</p>
|
<p className="text-foreground py-4">Community Support</p>
|
||||||
<p className="py-4 text-slate-900">Free, Forever</p>
|
<p className="text-foreground py-4">Free, Forever</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-plan="community"
|
data-plan="community"
|
||||||
className="border-primary flex flex-col items-center justify-center rounded-lg border-2 bg-white px-8 py-12 shadow-[0px_0px_0px_4px_#E3E3E380] shadow-slate-900/5"
|
className="border-primary bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border-2 px-8 py-12 shadow-[0px_0px_0px_4px_#E3E3E380]"
|
||||||
>
|
>
|
||||||
<p className="text-4xl font-medium text-slate-900">Community</p>
|
<p className="text-foreground text-4xl font-medium">Early Adopters</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>}
|
||||||
@ -121,7 +127,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 max-w-[30ch] text-center text-slate-900">
|
<p className="text-foreground mt-4 max-w-[30ch] text-center">
|
||||||
For fast-growing companies that aim to scale across multiple teams.
|
For fast-growing companies that aim to scale across multiple teams.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -130,25 +136,35 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</ClaimPlanDialog>
|
</ClaimPlanDialog>
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
<div className="mt-8 flex w-full flex-col divide-y">
|
||||||
<p className="py-4 font-medium text-slate-900">Documenso Early Adopter Deal:</p>
|
<p className="text-foreground py-4 font-medium">
|
||||||
<p className="py-4 text-slate-900">Join the movement</p>
|
{' '}
|
||||||
<p className="py-4 text-slate-900">Simple signing solution</p>
|
<a href="https://documenso.com/blog/early-adopters" target="_blank">
|
||||||
<p className="py-4 text-slate-900">Email and Slack assistance</p>
|
The Early Adopter Deal:
|
||||||
<p className="py-4 text-slate-900">
|
</a>
|
||||||
<strong>Includes all upcoming features</strong>
|
|
||||||
</p>
|
</p>
|
||||||
<p className="py-4 text-slate-900">Fixed, straightforward pricing</p>
|
<p className="text-foreground py-4">Join the movement</p>
|
||||||
|
<p className="text-foreground py-4">Simple signing solution</p>
|
||||||
|
<p className="text-foreground py-4">Email, Discord and Slack assistance</p>
|
||||||
|
<p className="text-foreground py-4">
|
||||||
|
<strong>
|
||||||
|
{' '}
|
||||||
|
<a href="https://documenso.com/blog/early-adopters" target="_blank">
|
||||||
|
Includes all upcoming features
|
||||||
|
</a>
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-foreground py-4">Fixed, straightforward pricing</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-plan="enterprise"
|
data-plan="enterprise"
|
||||||
className="flex flex-col items-center justify-center rounded-lg border bg-white px-8 py-12 shadow-lg shadow-slate-900/5"
|
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-4xl font-medium text-slate-900">Enterprise</p>
|
<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-primary mt-2.5 text-xl font-medium">Pricing on request</p>
|
||||||
|
|
||||||
<p className="mt-4 max-w-[30ch] text-center text-slate-900">
|
<p className="text-foreground mt-4 max-w-[30ch] text-center">
|
||||||
For large organizations that need extra flexibility and control.
|
For large organizations that need extra flexibility and control.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -162,12 +178,12 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
<div className="mt-8 flex w-full flex-col divide-y">
|
||||||
<p className="py-4 font-medium text-slate-900">Everything in Community, plus:</p>
|
<p className="text-foreground py-4 font-medium">Everything in Early Adopters, plus:</p>
|
||||||
<p className="py-4 text-slate-900">Custom Subdomain</p>
|
<p className="text-foreground py-4">Custom Subdomain</p>
|
||||||
<p className="py-4 text-slate-900">Compliance Check</p>
|
<p className="text-foreground py-4">Compliance Check</p>
|
||||||
<p className="py-4 text-slate-900">Guaranteed Uptime</p>
|
<p className="text-foreground py-4">Guaranteed Uptime</p>
|
||||||
<p className="py-4 text-slate-900">Reporting & Analysis</p>
|
<p className="text-foreground py-4">Reporting & Analysis</p>
|
||||||
<p className="py-4 text-slate-900">24/7 Support</p>
|
<p className="text-foreground py-4">24/7 Support</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export const ShareConnectPaidWidgetBento = ({
|
|||||||
<Image
|
<Image
|
||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="h-full scale-125 object-cover md:scale-150 lg:scale-[175%]"
|
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
||||||
@ -34,54 +34,70 @@ export const ShareConnectPaidWidgetBento = ({
|
|||||||
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
<div className="mt-6 grid grid-cols-2 gap-8 md:mt-8">
|
||||||
<Card className="col-span-2 lg:col-span-1" degrees={120} gradient>
|
<Card className="col-span-2 lg:col-span-1" degrees={120} gradient>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Easy Sharing (Soon).</strong>
|
<strong className="block">Easy Sharing (Soon).</strong>
|
||||||
Receive your personal link to share with everyone you care about.
|
Receive your personal link to share with everyone you care about.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardSharingFigure} alt="its fast" className="w-full max-w-xs" />
|
<Image
|
||||||
|
src={cardSharingFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-xs dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Connections (Soon).</strong>
|
<strong className="block">Connections (Soon).</strong>
|
||||||
Create connections and automations with Zapier and more to integrate with your
|
Create connections and automations with Zapier and more to integrate with your
|
||||||
favorite tools.
|
favorite tools.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardConnectionsFigure} alt="its fast" className="w-full max-w-sm" />
|
<Image
|
||||||
|
src={cardConnectionsFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-sm dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Get paid (Soon).</strong>
|
<strong className="block">Get paid (Soon).</strong>
|
||||||
Integrated payments with stripe so you don’t have to worry about getting paid.
|
Integrated payments with stripe so you don’t have to worry about getting paid.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardPaidFigure} alt="its fast" className="w-full max-w-[14rem]" />
|
<Image
|
||||||
|
src={cardPaidFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-[14rem] dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="leading-relaxed text-[#555E67]">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">React Widget (Soon).</strong>
|
<strong className="block">React Widget (Soon).</strong>
|
||||||
Easily embed Documenso into your product. Simply copy and paste our react widget into
|
Easily embed Documenso into your product. Simply copy and paste our react widget into
|
||||||
your application.
|
your application.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Image src={cardWidgetFigure} alt="its fast" className="w-full max-w-xs" />
|
<Image
|
||||||
|
src={cardWidgetFigure}
|
||||||
|
alt="its fast"
|
||||||
|
className="w-full max-w-xs dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -0,0 +1,233 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { render } from '@documenso/email/render';
|
||||||
|
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
|
||||||
|
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
|
||||||
|
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
|
||||||
|
import { alphaid } from '@documenso/lib/universal/id';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import {
|
||||||
|
DocumentDataType,
|
||||||
|
DocumentStatus,
|
||||||
|
FieldType,
|
||||||
|
Prisma,
|
||||||
|
ReadStatus,
|
||||||
|
SendStatus,
|
||||||
|
SigningStatus,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
const ZCreateSinglePlayerDocumentSchema = z.object({
|
||||||
|
documentData: z.object({
|
||||||
|
data: z.string(),
|
||||||
|
type: z.nativeEnum(DocumentDataType),
|
||||||
|
}),
|
||||||
|
documentName: z.string(),
|
||||||
|
signer: z.object({
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
name: z.string(),
|
||||||
|
signature: z.string(),
|
||||||
|
}),
|
||||||
|
fields: z.array(
|
||||||
|
z.object({
|
||||||
|
page: z.number(),
|
||||||
|
type: z.nativeEnum(FieldType),
|
||||||
|
positionX: z.number(),
|
||||||
|
positionY: z.number(),
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateSinglePlayerDocumentSchema = z.infer<typeof ZCreateSinglePlayerDocumentSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and self signs a document.
|
||||||
|
*
|
||||||
|
* Returns the document token.
|
||||||
|
*/
|
||||||
|
export const createSinglePlayerDocument = async (
|
||||||
|
value: TCreateSinglePlayerDocumentSchema,
|
||||||
|
): Promise<string> => {
|
||||||
|
const { signer, fields, documentData, documentName } =
|
||||||
|
ZCreateSinglePlayerDocumentSchema.parse(value);
|
||||||
|
|
||||||
|
const document = await getFile({
|
||||||
|
data: documentData.data,
|
||||||
|
type: documentData.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = await PDFDocument.load(document);
|
||||||
|
const createdAt = new Date();
|
||||||
|
|
||||||
|
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
|
||||||
|
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
|
||||||
|
const typedSignature = !isBase64 ? signer.signature : null;
|
||||||
|
|
||||||
|
// Update the document with the fields inserted.
|
||||||
|
for (const field of fields) {
|
||||||
|
const isSignatureField = field.type === FieldType.SIGNATURE;
|
||||||
|
|
||||||
|
await insertFieldInPDF(doc, {
|
||||||
|
...mapField(field, signer),
|
||||||
|
Signature: isSignatureField
|
||||||
|
? {
|
||||||
|
created: createdAt,
|
||||||
|
signatureImageAsBase64,
|
||||||
|
typedSignature,
|
||||||
|
// Dummy data.
|
||||||
|
id: -1,
|
||||||
|
recipientId: -1,
|
||||||
|
fieldId: -1,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
// Dummy data.
|
||||||
|
id: -1,
|
||||||
|
documentId: -1,
|
||||||
|
recipientId: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBytes = await doc.save();
|
||||||
|
|
||||||
|
const documentToken = await prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const documentToken = alphaid();
|
||||||
|
|
||||||
|
// Fetch service user who will be the owner of the document.
|
||||||
|
const serviceUser = await tx.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
email: SERVICE_USER_EMAIL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentDataBytes = Buffer.from(pdfBytes).toString('base64');
|
||||||
|
|
||||||
|
const { id: documentDataId } = await tx.documentData.create({
|
||||||
|
data: {
|
||||||
|
type: DocumentDataType.BYTES_64,
|
||||||
|
data: documentDataBytes,
|
||||||
|
initialData: documentDataBytes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create document.
|
||||||
|
const document = await tx.document.create({
|
||||||
|
data: {
|
||||||
|
title: documentName,
|
||||||
|
status: DocumentStatus.COMPLETED,
|
||||||
|
documentDataId,
|
||||||
|
userId: serviceUser.id,
|
||||||
|
createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create recipient.
|
||||||
|
const recipient = await tx.recipient.create({
|
||||||
|
data: {
|
||||||
|
documentId: document.id,
|
||||||
|
name: signer.name,
|
||||||
|
email: signer.email,
|
||||||
|
token: documentToken,
|
||||||
|
signedAt: createdAt,
|
||||||
|
readStatus: ReadStatus.OPENED,
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create fields and signatures.
|
||||||
|
await Promise.all(
|
||||||
|
fields.map(async (field) => {
|
||||||
|
const insertedField = await tx.field.create({
|
||||||
|
data: {
|
||||||
|
documentId: document.id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
...mapField(field, signer),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
||||||
|
await tx.signature.create({
|
||||||
|
data: {
|
||||||
|
fieldId: insertedField.id,
|
||||||
|
signatureImageAsBase64,
|
||||||
|
typedSignature,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return documentToken;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxWait: 5000,
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const template = createElement(DocumentSelfSignedEmailTemplate, {
|
||||||
|
documentName: documentName,
|
||||||
|
assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email to signer.
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: signer.email,
|
||||||
|
name: signer.name,
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: FROM_NAME,
|
||||||
|
address: FROM_ADDRESS,
|
||||||
|
},
|
||||||
|
subject: 'Document signed',
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
attachments: [{ content: Buffer.from(pdfBytes), filename: documentName }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return documentToken;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the fields provided by the user to fields compatible with Prisma.
|
||||||
|
*
|
||||||
|
* Signature fields are handled separately.
|
||||||
|
*
|
||||||
|
* @param field The field passed in by the user.
|
||||||
|
* @param signer The details of the person who is signing this document.
|
||||||
|
* @returns A field compatible with Prisma.
|
||||||
|
*/
|
||||||
|
const mapField = (
|
||||||
|
field: TCreateSinglePlayerDocumentSchema['fields'][number],
|
||||||
|
signer: TCreateSinglePlayerDocumentSchema['signer'],
|
||||||
|
) => {
|
||||||
|
const customText = match(field.type)
|
||||||
|
.with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a'))
|
||||||
|
.with(FieldType.EMAIL, () => signer.email)
|
||||||
|
.with(FieldType.NAME, () => signer.name)
|
||||||
|
.otherwise(() => '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: field.type,
|
||||||
|
page: field.page,
|
||||||
|
positionX: new Prisma.Decimal(field.positionX),
|
||||||
|
positionY: new Prisma.Decimal(field.positionY),
|
||||||
|
width: new Prisma.Decimal(field.width),
|
||||||
|
height: new Prisma.Decimal(field.height),
|
||||||
|
customText,
|
||||||
|
inserted: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Share } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
|
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||||
|
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
||||||
|
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||||
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import signingCelebration from '~/assets/signing-celebration.png';
|
||||||
|
import ConfettiScreen from '~/components/(marketing)/confetti-screen';
|
||||||
|
|
||||||
|
import { DocumentStatus } from '.prisma/client';
|
||||||
|
|
||||||
|
interface SinglePlayerModeSuccessProps {
|
||||||
|
className?: string;
|
||||||
|
document: DocumentWithRecipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerModeSuccessProps) => {
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const isConfettiEnabled = getFlag('marketing_spm_confetti');
|
||||||
|
|
||||||
|
const [showDocumentDialog, setShowDocumentDialog] = useState(false);
|
||||||
|
const [isFetchingDocumentFile, setIsFetchingDocumentFile] = useState(false);
|
||||||
|
const [documentFile, setDocumentFile] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const onShowDocumentClick = async () => {
|
||||||
|
if (isFetchingDocumentFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFetchingDocumentFile(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getFile(document.documentData);
|
||||||
|
|
||||||
|
setDocumentFile(base64.encode(data));
|
||||||
|
|
||||||
|
setShowDocumentDialog(true);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong.',
|
||||||
|
description: 'We were unable to retrieve the document at this time. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFetchingDocumentFile(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[calc(100vh-10rem)] flex-col items-center justify-center sm:min-h-[calc(100vh-13rem)]">
|
||||||
|
{isConfettiEnabled && (
|
||||||
|
<ConfettiScreen duration={3000} gravity={0.075} initialVelocityY={50} wind={0.005} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className="relative z-10 text-center text-2xl font-semibold leading-normal md:text-3xl lg:mb-2 lg:text-4xl">
|
||||||
|
You have signed
|
||||||
|
<span className="mt-2 block">{document.title}</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<SigningCard3D
|
||||||
|
className="mt-8"
|
||||||
|
name={document.Recipient.name || document.Recipient.email}
|
||||||
|
signingCelebrationImage={signingCelebration}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative mt-8 w-full">
|
||||||
|
<div className={cn('flex flex-col items-center', className)}>
|
||||||
|
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
|
||||||
|
{/* TODO: Hook this up */}
|
||||||
|
<Button variant="outline" className="flex-1 bg-transparent backdrop-blur-sm" disabled>
|
||||||
|
<Share className="mr-2 h-5 w-5" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DocumentDownloadButton
|
||||||
|
className="flex-1 bg-transparent backdrop-blur-sm"
|
||||||
|
fileName={document.title}
|
||||||
|
documentData={document.documentData}
|
||||||
|
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={async () => onShowDocumentClick()}
|
||||||
|
loading={isFetchingDocumentFile}
|
||||||
|
className="col-span-2"
|
||||||
|
>
|
||||||
|
Show document
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground/60 mt-16 text-center text-sm">
|
||||||
|
Create a{' '}
|
||||||
|
<Link
|
||||||
|
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
|
||||||
|
target="_blank"
|
||||||
|
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
free account
|
||||||
|
</Link>{' '}
|
||||||
|
to access your signed documents at any time
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DocumentDialog
|
||||||
|
document={documentFile ?? ''}
|
||||||
|
open={showDocumentDialog}
|
||||||
|
onOpenChange={setShowDocumentDialog}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -181,16 +181,16 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-12 gap-y-8 overflow-hidden p-2 lg:gap-x-8">
|
<div className="grid grid-cols-12 gap-y-8 overflow-hidden p-2 lg:gap-x-8">
|
||||||
<div className="col-span-12 flex flex-col gap-y-4 p-4 text-xs leading-relaxed text-[#727272] lg:col-span-7">
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="col-span-12 flex flex-col rounded-2xl bg-[#F7F7F7] p-6 lg:col-span-5"
|
className="bg-foreground/5 col-span-12 flex flex-col rounded-2xl p-6 lg:col-span-5"
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<h3 className="text-2xl font-semibold">Sign up for the community plan</h3>
|
<h3 className="text-2xl font-semibold">Sign up for the early adopters plan</h3>
|
||||||
<p className="mt-2 text-xs text-[#AFAFAF]">
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
with Timur Ercan & Lucas Smith from Documenso
|
with Timur Ercan & Lucas Smith from Documenso
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -198,7 +198,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div key="email">
|
<motion.div key="email">
|
||||||
<label htmlFor="email" className="text-lg font-semibold text-slate-900 lg:text-xl">
|
<label htmlFor="email" className="text-foreground text-lg font-semibold lg:text-xl">
|
||||||
What’s your email?
|
What’s your email?
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@ -211,7 +211,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder=""
|
placeholder=""
|
||||||
className="w-full bg-white pr-16"
|
className="bg-background w-full pr-16"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onKeyDown={(e) =>
|
onKeyDown={(e) =>
|
||||||
field.value !== '' &&
|
field.value !== '' &&
|
||||||
@ -255,7 +255,10 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
transform: 'translateX(25%)',
|
transform: 'translateX(25%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label htmlFor="name" className="text-lg font-semibold text-slate-900 lg:text-xl">
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="text-foreground text-lg font-semibold lg:text-xl"
|
||||||
|
>
|
||||||
and your name?
|
and your name?
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@ -268,7 +271,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder=""
|
placeholder=""
|
||||||
className="w-full bg-white pr-16"
|
className="bg-background w-full pr-16"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onKeyDown={(e) =>
|
onKeyDown={(e) =>
|
||||||
field.value !== '' &&
|
field.value !== '' &&
|
||||||
@ -300,11 +303,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
<div className="mt-12 flex-1" />
|
<div className="mt-12 flex-1" />
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-[#AFAFAF]">{stepsRemaining} step(s) until signed</p>
|
<p className="text-muted-foreground text-xs">{stepsRemaining} step(s) until signed</p>
|
||||||
<p className="block text-xs text-[#AFAFAF] md:hidden">Minimise contract</p>
|
<p className="text-muted-foreground block text-xs md:hidden">Minimise contract</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mt-2.5 h-[2px] w-full bg-[#E9E9E9]">
|
<div className="bg-background relative mt-2.5 h-[2px] w-full">
|
||||||
<div
|
<div
|
||||||
className={cn('bg-primary/60 absolute inset-y-0 left-0 duration-200', {
|
className={cn('bg-primary/60 absolute inset-y-0 left-0 duration-200', {
|
||||||
'w-1/3': stepsRemaining === 3,
|
'w-1/3': stepsRemaining === 3,
|
||||||
@ -322,13 +325,17 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
>
|
>
|
||||||
<div className="flex h-28 items-center justify-center pb-6">
|
<div className="flex h-28 items-center justify-center pb-6">
|
||||||
{!signatureText && signatureDataUrl && (
|
{!signatureText && signatureDataUrl && (
|
||||||
<img src={signatureDataUrl} alt="user signature" className="h-full" />
|
<img
|
||||||
|
src={signatureDataUrl}
|
||||||
|
alt="user signature"
|
||||||
|
className="h-full dark:invert"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{signatureText && (
|
{signatureText && (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-4xl font-semibold text-slate-900 [font-family:var(--font-caveat)]',
|
'text-foreground text-4xl font-semibold [font-family:var(--font-caveat)]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{signatureText}
|
{signatureText}
|
||||||
@ -342,7 +349,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="signatureText"
|
id="signatureText"
|
||||||
className="border-none p-0 text-sm text-slate-700 placeholder:text-[#D6D6D6] focus-visible:ring-0"
|
className="text-foreground placeholder:text-muted-foreground border-none p-0 text-sm focus-visible:ring-0"
|
||||||
placeholder="Draw or type name here"
|
placeholder="Draw or type name here"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...register('signatureText', {
|
{...register('signatureText', {
|
||||||
@ -356,7 +363,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="h-8 disabled:bg-[#ECEEED] disabled:text-[#C6C6C6] disabled:hover:bg-[#ECEEED]"
|
className="disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted h-8"
|
||||||
disabled={!isValid || isSubmitting}
|
disabled={!isValid || isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
|
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
|||||||
7
apps/marketing/src/pages/api/feature-flag/all.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
runtime: 'edge',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handlerFeatureFlagAll;
|
||||||
7
apps/marketing/src/pages/api/feature-flag/get.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
runtime: 'edge',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handlerFeatureFlagGet;
|
||||||
10
apps/marketing/src/providers/next-theme.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
|
import { ThemeProviderProps } from 'next-themes/dist/types';
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
39
apps/marketing/src/providers/posthog.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import posthog from 'posthog-js';
|
||||||
|
|
||||||
|
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||||
|
|
||||||
|
export function PostHogPageview() {
|
||||||
|
const postHogConfig = extractPostHogConfig();
|
||||||
|
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && postHogConfig) {
|
||||||
|
posthog.init(postHogConfig.key, {
|
||||||
|
api_host: postHogConfig.host,
|
||||||
|
disable_session_recording: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!postHogConfig || !pathname) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = window.origin + pathname;
|
||||||
|
if (searchParams && searchParams.toString()) {
|
||||||
|
url = url + `?${searchParams.toString()}`;
|
||||||
|
}
|
||||||
|
posthog.capture('$pageview', {
|
||||||
|
$current_url: url,
|
||||||
|
});
|
||||||
|
}, [pathname, searchParams, postHogConfig]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
BIN
apps/web/example/cert.p12
Normal file
@ -2,7 +2,7 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { version } = require('./package.json');
|
const { version } = require('./package.json');
|
||||||
|
|
||||||
const { parsed: env } = require('dotenv').config({
|
require('dotenv').config({
|
||||||
path: path.join(__dirname, '../../.env.local'),
|
path: path.join(__dirname, '../../.env.local'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -22,12 +22,21 @@ const config = {
|
|||||||
],
|
],
|
||||||
env: {
|
env: {
|
||||||
APP_VERSION: version,
|
APP_VERSION: version,
|
||||||
|
NEXT_PUBLIC_PROJECT: 'web',
|
||||||
},
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/ingest/:path*',
|
||||||
|
destination: 'https://eu.posthog.com/:path*',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|||||||
BIN
apps/web/public/static/user-plus.png
Normal file
|
After Width: | Height: | Size: 367 B |
@ -12,7 +12,7 @@ export type AdminSectionLayoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
|
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
|
||||||
const user = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
if (!isAdmin(user)) {
|
if (!isAdmin(user)) {
|
||||||
redirect('/documents');
|
redirect('/documents');
|
||||||
|
|||||||
@ -55,21 +55,18 @@ export const EditDocumentForm = ({
|
|||||||
title: 'Add Signers',
|
title: 'Add Signers',
|
||||||
description: 'Add the people who will sign the document.',
|
description: 'Add the people who will sign the document.',
|
||||||
stepIndex: 1,
|
stepIndex: 1,
|
||||||
onSubmit: () => onAddSignersFormSubmit,
|
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Add Fields',
|
title: 'Add Fields',
|
||||||
description: 'Add all relevant fields for each recipient.',
|
description: 'Add all relevant fields for each recipient.',
|
||||||
stepIndex: 2,
|
stepIndex: 2,
|
||||||
onBackStep: () => setStep('signers'),
|
onBackStep: () => setStep('signers'),
|
||||||
onSubmit: () => onAddFieldsFormSubmit,
|
|
||||||
},
|
},
|
||||||
subject: {
|
subject: {
|
||||||
title: 'Add Subject',
|
title: 'Add Subject',
|
||||||
description: 'Add the subject and message you wish to send to signers.',
|
description: 'Add the subject and message you wish to send to signers.',
|
||||||
stepIndex: 3,
|
stepIndex: 3,
|
||||||
onBackStep: () => setStep('fields'),
|
onBackStep: () => setStep('fields'),
|
||||||
onSubmit: () => onAddSubjectFormSubmit,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -169,6 +166,7 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
{step === 'signers' && (
|
{step === 'signers' && (
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
@ -179,6 +177,7 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
{step === 'fields' && (
|
{step === 'fields' && (
|
||||||
<AddFieldsFormPartial
|
<AddFieldsFormPartial
|
||||||
|
key={fields.length}
|
||||||
documentFlow={documentFlow.fields}
|
documentFlow={documentFlow.fields}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export default function Loading() {
|
|||||||
Documents
|
Documents
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
Loading Document...
|
Loading Document...
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mt-8 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
<div className="mt-8 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
||||||
|
|||||||
@ -30,11 +30,11 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
redirect('/documents');
|
redirect('/documents');
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const document = await getDocumentById({
|
const document = await getDocumentById({
|
||||||
id: documentId,
|
id: documentId,
|
||||||
userId: session.id,
|
userId: user.id,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!document || !document.documentData) {
|
if (!document || !document.documentData) {
|
||||||
@ -50,11 +50,11 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
const [recipients, fields] = await Promise.all([
|
const [recipients, fields] = await Promise.all([
|
||||||
await getRecipientsForDocument({
|
await getRecipientsForDocument({
|
||||||
documentId,
|
documentId,
|
||||||
userId: session.id,
|
userId: user.id,
|
||||||
}),
|
}),
|
||||||
await getFieldsForDocument({
|
await getFieldsForDocument({
|
||||||
documentId,
|
documentId,
|
||||||
userId: session.id,
|
userId: user.id,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -65,10 +65,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
Documents
|
Documents
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
className="mt-4 max-w-xs truncate text-2xl font-semibold md:text-3xl"
|
|
||||||
title={document.title}
|
|
||||||
>
|
|
||||||
{document.title}
|
{document.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@ -90,7 +87,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
<EditDocumentForm
|
<EditDocumentForm
|
||||||
className="mt-8"
|
className="mt-8"
|
||||||
document={document}
|
document={document}
|
||||||
user={session}
|
user={user}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
dataUrl={documentDataUrl}
|
dataUrl={documentDataUrl}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export default function DocumentSentPage() {
|
|||||||
Documents
|
Documents
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
Loading Document...
|
Loading Document...
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -112,7 +112,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<MoreHorizontal className="h-5 w-5 text-gray-500" />
|
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
|
|||||||
@ -92,8 +92,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
{isPending && (
|
{isPending && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
||||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export type DocumentsPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||||
const user = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const stats = await getStats({
|
const stats = await getStats({
|
||||||
user,
|
user,
|
||||||
@ -63,36 +63,38 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<UploadDocument />
|
<UploadDocument />
|
||||||
|
|
||||||
<h1 className="mt-12 text-4xl font-semibold">Documents</h1>
|
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||||
|
<h1 className="text-4xl font-semibold">Documents</h1>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
|
<div className="flex flex-wrap gap-x-4 gap-y-6 overflow-hidden">
|
||||||
<Tabs defaultValue={status} className="overflow-x-auto">
|
<Tabs defaultValue={status} className="overflow-x-auto">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{[
|
{[
|
||||||
ExtendedDocumentStatus.INBOX,
|
ExtendedDocumentStatus.INBOX,
|
||||||
ExtendedDocumentStatus.PENDING,
|
ExtendedDocumentStatus.PENDING,
|
||||||
ExtendedDocumentStatus.COMPLETED,
|
ExtendedDocumentStatus.COMPLETED,
|
||||||
ExtendedDocumentStatus.DRAFT,
|
ExtendedDocumentStatus.DRAFT,
|
||||||
ExtendedDocumentStatus.ALL,
|
ExtendedDocumentStatus.ALL,
|
||||||
].map((value) => (
|
].map((value) => (
|
||||||
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
|
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
|
||||||
<Link href={getTabHref(value)} scroll={false}>
|
<Link href={getTabHref(value)} scroll={false}>
|
||||||
<DocumentStatus status={value} />
|
<DocumentStatus status={value} />
|
||||||
|
|
||||||
{value !== ExtendedDocumentStatus.ALL && (
|
{value !== ExtendedDocumentStatus.ALL && (
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||||
{Math.min(stats[value], 99)}
|
{Math.min(stats[value], 99)}
|
||||||
{stats[value] > 99 && '+'}
|
{stats[value] > 99 && '+'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||||
<PeriodSelector />
|
<PeriodSelector />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -67,7 +67,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
<DocumentDropzone className="min-h-[40vh]" onDrop={onFileDrop} />
|
<DocumentDropzone className="min-h-[40vh]" onDrop={onFileDrop} />
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
||||||
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export default async function AuthenticatedDashboardLayout({
|
|||||||
redirect('/signin');
|
redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextAuthProvider session={session}>
|
<NextAuthProvider session={session}>
|
||||||
|
|||||||
@ -4,15 +4,15 @@ import { redirect } from 'next/navigation';
|
|||||||
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
|
|
||||||
|
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
const user = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
|||||||
import { PasswordForm } from '~/components/forms/password';
|
import { PasswordForm } from '~/components/forms/password';
|
||||||
|
|
||||||
export default async function PasswordSettingsPage() {
|
export default async function PasswordSettingsPage() {
|
||||||
const user = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
|||||||
import { ProfileForm } from '~/components/forms/profile';
|
import { ProfileForm } from '~/components/forms/profile';
|
||||||
|
|
||||||
export default async function ProfileSettingsPage() {
|
export default async function ProfileSettingsPage() {
|
||||||
const user = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { ImageResponse } from 'next/server';
|
import { ImageResponse, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { P, match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ type SharePageOpenGraphImageProps = {
|
|||||||
params: { slug: string };
|
params: { slug: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Image({ params: { slug } }: SharePageOpenGraphImageProps) {
|
export async function GET(_request: Request, { params: { slug } }: SharePageOpenGraphImageProps) {
|
||||||
const [interSemiBold, interRegular, caveatRegular, shareFrameImage] = await Promise.all([
|
const [interSemiBold, interRegular, caveatRegular, shareFrameImage] = await Promise.all([
|
||||||
getAssetBuffer('/fonts/inter-semibold.ttf'),
|
getAssetBuffer('/fonts/inter-semibold.ttf'),
|
||||||
getAssetBuffer('/fonts/inter-regular.ttf'),
|
getAssetBuffer('/fonts/inter-regular.ttf'),
|
||||||
@ -32,7 +32,7 @@ export default async function Image({ params: { slug } }: SharePageOpenGraphImag
|
|||||||
const recipientOrSender = await getRecipientOrSenderByShareLinkSlug({ slug }).catch(() => null);
|
const recipientOrSender = await getRecipientOrSenderByShareLinkSlug({ slug }).catch(() => null);
|
||||||
|
|
||||||
if (!recipientOrSender) {
|
if (!recipientOrSender) {
|
||||||
return null;
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRecipient = 'Signature' in recipientOrSender;
|
const isRecipient = 'Signature' in recipientOrSender;
|
||||||
@ -1,11 +1,39 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { Redirect } from './redirect';
|
import { APP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
type SharePageProps = {
|
||||||
title: 'Documenso - Share',
|
params: { slug: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SharePage() {
|
export function generateMetadata({ params: { slug } }: SharePageProps) {
|
||||||
return <Redirect />;
|
return {
|
||||||
|
title: 'Documenso - Share',
|
||||||
|
description: 'I just signed a document with Documenso!',
|
||||||
|
openGraph: {
|
||||||
|
title: 'Documenso - Join the open source signing revolution',
|
||||||
|
description: 'I just signed with Documenso!',
|
||||||
|
type: 'website',
|
||||||
|
images: [`${APP_BASE_URL}/share/${slug}/opengraph`],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
site: '@documenso',
|
||||||
|
card: 'summary_large_image',
|
||||||
|
images: [`${APP_BASE_URL}/share/${slug}/opengraph`],
|
||||||
|
description: 'I just signed with Documenso!',
|
||||||
|
},
|
||||||
|
} satisfies Metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SharePage() {
|
||||||
|
const userAgent = headers().get('User-Agent') ?? '';
|
||||||
|
|
||||||
|
// https://stackoverflow.com/questions/47026171/how-to-detect-bots-for-open-graph-with-user-agent
|
||||||
|
if (/bot|facebookexternalhit|WhatsApp|google|bing|duckduckbot|MetaInspector/i.test(userAgent)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
export const Redirect = () => {
|
|
||||||
useEffect(() => {
|
|
||||||
window.location.href = process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@ -8,10 +8,12 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
|
|||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
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 { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
||||||
|
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||||
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
|
|
||||||
|
import signingCelebration from '~/assets/signing-celebration.png';
|
||||||
|
|
||||||
import { DownloadButton } from './download-button';
|
|
||||||
import { ShareButton } from './share-button';
|
import { ShareButton } from './share-button';
|
||||||
import { SigningCard } from './signing-card';
|
|
||||||
|
|
||||||
export type CompletedSigningPageProps = {
|
export type CompletedSigningPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@ -51,11 +53,11 @@ export default async function CompletedSigningPage({
|
|||||||
recipient.email;
|
recipient.email;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center pt-24">
|
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
|
||||||
{/* Card with recipient */}
|
{/* Card with recipient */}
|
||||||
<SigningCard name={recipientName} />
|
<SigningCard3D name={recipientName} signingCelebrationImage={signingCelebration} />
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="relative mt-6 flex w-full flex-col items-center">
|
||||||
{match(document.status)
|
{match(document.status)
|
||||||
.with(DocumentStatus.COMPLETED, () => (
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
<div className="text-documenso-700 flex items-center text-center">
|
<div className="text-documenso-700 flex items-center text-center">
|
||||||
@ -69,41 +71,44 @@ export default async function CompletedSigningPage({
|
|||||||
<span className="text-sm">Waiting for others to sign</span>
|
<span className="text-sm">Waiting for others to sign</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||||
|
You have signed "{document.title}"
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{match(document.status)
|
||||||
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
|
Everyone has signed! You will receive an Email copy of the signed document.
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
|
You will receive an Email copy of the signed document once everyone has signed.
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||||
|
<ShareButton documentId={document.id} token={recipient.token} />
|
||||||
|
|
||||||
|
<DocumentDownloadButton
|
||||||
|
className="flex-1"
|
||||||
|
fileName={document.title}
|
||||||
|
documentData={documentData}
|
||||||
|
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||||
|
Want to send slick signing links like this one?{' '}
|
||||||
|
<Link
|
||||||
|
href="https://documenso.com"
|
||||||
|
className="text-documenso-700 hover:text-documenso-600"
|
||||||
|
>
|
||||||
|
Check out Documenso.
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
|
||||||
You have signed "{document.title}"
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{match(document.status)
|
|
||||||
.with(DocumentStatus.COMPLETED, () => (
|
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
|
||||||
Everyone has signed! You will receive an Email copy of the signed document.
|
|
||||||
</p>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
|
||||||
You will receive an Email copy of the signed document once everyone has signed.
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
|
||||||
<ShareButton documentId={document.id} token={recipient.token} />
|
|
||||||
|
|
||||||
<DownloadButton
|
|
||||||
className="flex-1"
|
|
||||||
fileName={document.title}
|
|
||||||
documentData={documentData}
|
|
||||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
|
||||||
Want so send slick signing links like this one?{' '}
|
|
||||||
<Link href="https://documenso.com" className="text-documenso-700 hover:text-documenso-600">
|
|
||||||
Check out Documenso.
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes, useState } from 'react';
|
||||||
|
|
||||||
import { Share } from 'lucide-react';
|
import { Copy, Share, Twitter } from 'lucide-react';
|
||||||
|
|
||||||
|
import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
||||||
@ -19,14 +28,36 @@ export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [, copyToClipboard] = useCopyToClipboard();
|
const [, copyToClipboard] = useCopyToClipboard();
|
||||||
|
|
||||||
const { mutateAsync: createOrGetShareLink, isLoading } =
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
trpc.shareLink.createOrGetShareLink.useMutation();
|
|
||||||
|
|
||||||
const onShareClick = async () => {
|
const {
|
||||||
const { slug } = await createOrGetShareLink({
|
mutateAsync: createOrGetShareLink,
|
||||||
token: token,
|
data: shareLink,
|
||||||
documentId,
|
isLoading,
|
||||||
});
|
} = trpc.shareLink.createOrGetShareLink.useMutation();
|
||||||
|
|
||||||
|
const onOpenChange = (nextOpen: boolean) => {
|
||||||
|
if (nextOpen) {
|
||||||
|
void createOrGetShareLink({
|
||||||
|
token,
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(nextOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCopyClick = async () => {
|
||||||
|
let { slug = '' } = shareLink || {};
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
const result = await createOrGetShareLink({
|
||||||
|
token,
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
slug = result.slug;
|
||||||
|
}
|
||||||
|
|
||||||
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
|
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
|
||||||
|
|
||||||
@ -34,18 +65,82 @@ export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
|
|||||||
title: 'Copied to clipboard',
|
title: 'Copied to clipboard',
|
||||||
description: 'The sharing link has been copied to your clipboard.',
|
description: 'The sharing link has been copied to your clipboard.',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTweetClick = async () => {
|
||||||
|
let { slug = '' } = shareLink || {};
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
const result = await createOrGetShareLink({
|
||||||
|
token,
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
slug = result.slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(
|
||||||
|
generateTwitterIntent(
|
||||||
|
`I just ${token ? 'signed' : 'sent'} a document with @documenso. Check it out!`,
|
||||||
|
`${window.location.origin}/share/${slug}`,
|
||||||
|
),
|
||||||
|
'_blank',
|
||||||
|
);
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
variant="outline"
|
<DialogTrigger asChild>
|
||||||
className="flex-1"
|
<Button
|
||||||
disabled={!token || !documentId}
|
variant="outline"
|
||||||
loading={isLoading}
|
disabled={!token || !documentId}
|
||||||
onClick={onShareClick}
|
className="flex-1"
|
||||||
>
|
loading={isLoading}
|
||||||
{!isLoading && <Share className="mr-2 h-5 w-5" />}
|
>
|
||||||
Share
|
{!isLoading && <Share className="mr-2 h-5 w-5" />}
|
||||||
</Button>
|
Share
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="end">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Share</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">Share your signing experience!</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col">
|
||||||
|
<div className="rounded-md border p-4">
|
||||||
|
I just {token ? 'signed' : 'sent'} a document with{' '}
|
||||||
|
<span className="font-medium text-blue-400">@documenso</span>
|
||||||
|
. Check it out!
|
||||||
|
<span className="mt-2 block" />
|
||||||
|
<span className="break-all font-medium text-blue-400">
|
||||||
|
{window.location.origin}/share/{shareLink?.slug || '...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" className="mt-4" onClick={onTweetClick}>
|
||||||
|
<Twitter className="mr-2 h-4 w-4" />
|
||||||
|
Tweet
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative flex items-center justify-center gap-x-4 py-4 text-xs uppercase">
|
||||||
|
<div className="bg-border h-px flex-1" />
|
||||||
|
<span className="text-muted-foreground bg-transparent">Or</span>
|
||||||
|
<div className="bg-border h-px flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={onCopyClick}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Copy Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
|
|
||||||
import signingCelebration from '~/assets/signing-celebration.png';
|
|
||||||
|
|
||||||
export type SigningCardProps = {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SigningCard = ({ name }: SigningCardProps) => {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full max-w-xs md:max-w-sm">
|
|
||||||
<Card
|
|
||||||
className="group mx-auto flex aspect-[21/9] w-full items-center justify-center"
|
|
||||||
degrees={-145}
|
|
||||||
gradient
|
|
||||||
>
|
|
||||||
<CardContent
|
|
||||||
className="font-signature p-6 text-center"
|
|
||||||
style={{
|
|
||||||
container: 'main',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
|
|
||||||
style={{
|
|
||||||
fontSize: `max(min(4rem, ${(100 / name.length / 2).toFixed(4)}cqw), 1.875rem)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="absolute -inset-32 -z-10 flex items-center justify-center md:-inset-44 xl:-inset-60 2xl:-inset-80"
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
scale: 0.8,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
scale: 1,
|
|
||||||
opacity: 0.5,
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
delay: 0.5,
|
|
||||||
duration: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={signingCelebration}
|
|
||||||
alt="background pattern"
|
|
||||||
className="w-full"
|
|
||||||
style={{
|
|
||||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
|
||||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -77,7 +77,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
|
|||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -81,7 +81,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
|||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||||
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import { Document, Field, Recipient } from '@documenso/prisma/client';
|
import { Document, Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@ -24,18 +28,26 @@ export type SigningFormProps = {
|
|||||||
|
|
||||||
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
|
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
||||||
|
|
||||||
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
} = useForm();
|
} = useForm();
|
||||||
|
|
||||||
const isComplete = fields.every((f) => f.inserted);
|
const uninsertedFields = useMemo(() => {
|
||||||
|
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
const onFormSubmit = async () => {
|
||||||
if (!isComplete) {
|
setValidateUninsertedFields(true);
|
||||||
|
const isFieldsValid = validateFieldsInserted(fields);
|
||||||
|
|
||||||
|
if (!isFieldsValid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,11 +62,24 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className={cn(
|
className={cn(
|
||||||
'dark:bg-background border-border bg-widget sticky top-20 flex h-full max-h-[80rem] flex-col rounded-xl border px-4 py-6',
|
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
|
||||||
|
{
|
||||||
|
'top-20 max-h-[min(68rem,calc(100vh-6rem))]': session,
|
||||||
|
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !session,
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<div className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}>
|
{validateUninsertedFields && uninsertedFields[0] && (
|
||||||
|
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||||
|
Click to insert field
|
||||||
|
</FieldToolTip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
||||||
|
>
|
||||||
<div className={cn('flex flex-1 flex-col')}>
|
<div className={cn('flex flex-1 flex-col')}>
|
||||||
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
||||||
|
|
||||||
@ -101,23 +126,19 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||||
|
onClick={() => router.back()}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button className="w-full" type="submit" size="lg" loading={isSubmitting}>
|
||||||
className="w-full"
|
|
||||||
type="submit"
|
|
||||||
size="lg"
|
|
||||||
disabled={!isComplete || isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
|
||||||
Complete
|
Complete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,11 +10,11 @@ export type SigningLayoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
||||||
const user = await getServerComponentSession();
|
const { user, session } = await getServerComponentSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextAuthProvider>
|
<NextAuthProvider session={session}>
|
||||||
<div className="min-h-screen overflow-hidden">
|
<div className="min-h-screen">
|
||||||
{user && <AuthenticatedHeader user={user} />}
|
{user && <AuthenticatedHeader user={user} />}
|
||||||
|
|
||||||
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
|
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
|
||||||
|
|||||||
@ -100,7 +100,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -9,7 +9,7 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum
|
|||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
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 { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
@ -51,11 +51,18 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
.then((buffer) => Buffer.from(buffer).toString('base64'))
|
.then((buffer) => Buffer.from(buffer).toString('base64'))
|
||||||
.then((data) => `data:application/pdf;base64,${data}`);
|
.then((data) => `data:application/pdf;base64,${data}`);
|
||||||
|
|
||||||
const user = await getServerComponentSession();
|
const { user } = await getServerComponentSession();
|
||||||
|
|
||||||
|
if (
|
||||||
|
document.status === DocumentStatus.COMPLETED ||
|
||||||
|
recipient.signingStatus === SigningStatus.SIGNED
|
||||||
|
) {
|
||||||
|
redirect(`/sign/${token}/complete`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningProvider email={recipient.email} fullName={recipient.name} signature={user?.signature}>
|
<SigningProvider email={recipient.email} fullName={recipient.name} signature={user?.signature}>
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl">
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
{document.title}
|
{document.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@ -115,7 +115,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -130,7 +130,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
<img
|
<img
|
||||||
src={signature.signatureImageAsBase64}
|
src={signature.signatureImageAsBase64}
|
||||||
alt={`Signature for ${recipient.name}`}
|
alt={`Signature for ${recipient.name}`}
|
||||||
className="h-full w-full object-contain"
|
className="h-full w-full object-contain dark:invert"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
|
|
||||||
import { useFieldPageCoords } from '~/hooks/use-field-page-coords';
|
|
||||||
|
|
||||||
export type SignatureFieldProps = {
|
export type SignatureFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
@ -23,8 +20,6 @@ export const SigningFieldContainer = ({
|
|||||||
onRemove,
|
onRemove,
|
||||||
children,
|
children,
|
||||||
}: SignatureFieldProps) => {
|
}: SignatureFieldProps) => {
|
||||||
const coords = useFieldPageCoords(field);
|
|
||||||
|
|
||||||
const onSignFieldClick = async () => {
|
const onSignFieldClick = async () => {
|
||||||
if (field.inserted) {
|
if (field.inserted) {
|
||||||
return;
|
return;
|
||||||
@ -42,40 +37,25 @@ export const SigningFieldContainer = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<FieldRootContainer field={field}>
|
||||||
className="absolute"
|
{!field.inserted && !loading && (
|
||||||
style={{
|
<button
|
||||||
top: `${coords.y}px`,
|
type="submit"
|
||||||
left: `${coords.x}px`,
|
className="absolute inset-0 z-10 h-full w-full"
|
||||||
height: `${coords.height}px`,
|
onClick={onSignFieldClick}
|
||||||
width: `${coords.width}px`,
|
/>
|
||||||
}}
|
)}
|
||||||
>
|
|
||||||
<Card
|
{field.inserted && !loading && (
|
||||||
className="bg-background relative h-full w-full"
|
<button
|
||||||
data-inserted={field.inserted ? 'true' : 'false'}
|
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
|
||||||
>
|
onClick={onRemoveSignedFieldClick}
|
||||||
<CardContent
|
|
||||||
className={cn(
|
|
||||||
'text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{!field.inserted && !loading && (
|
Remove
|
||||||
<button type="submit" className="absolute inset-0 z-10" onClick={onSignFieldClick} />
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && !loading && (
|
{children}
|
||||||
<button
|
</FieldRootContainer>
|
||||||
className="text-destructive bg-background/40 absolute inset-0 z-10 flex items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
|
|
||||||
onClick={onRemoveSignedFieldClick}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export default function UnauthenticatedLayout({ children }: UnauthenticatedLayou
|
|||||||
<Image
|
<Image
|
||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="dark:brightness-95 dark:invert dark:sepia"
|
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -2,15 +2,15 @@ import { Suspense } from 'react';
|
|||||||
|
|
||||||
import { Caveat, Inter } from 'next/font/google';
|
import { Caveat, Inter } from 'next/font/google';
|
||||||
|
|
||||||
|
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
|
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
|
||||||
|
import { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||||
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
import { getServerComponentAllFlags } from '~/helpers/get-server-component-feature-flag';
|
|
||||||
import { FeatureFlagProvider } from '~/providers/feature-flag';
|
|
||||||
import { ThemeProvider } from '~/providers/next-theme';
|
import { ThemeProvider } from '~/providers/next-theme';
|
||||||
import { PlausibleProvider } from '~/providers/plausible';
|
import { PlausibleProvider } from '~/providers/plausible';
|
||||||
import { PostHogPageview } from '~/providers/posthog';
|
import { PostHogPageview } from '~/providers/posthog';
|
||||||
@ -76,6 +76,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</PlausibleProvider>
|
</PlausibleProvider>
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</FeatureFlagProvider>
|
</FeatureFlagProvider>
|
||||||
</LocaleProvider>
|
</LocaleProvider>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
import NotFoundPartial from '~/components/partials/not-found';
|
import NotFoundPartial from '~/components/partials/not-found';
|
||||||
|
|
||||||
export default async function NotFound() {
|
export default async function NotFound() {
|
||||||
const session = await getServerComponentSession();
|
const { session } = await getServerComponentSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotFoundPartial>
|
<NotFoundPartial>
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip delayDuration={200}>
|
||||||
<TooltipTrigger className="flex cursor-pointer">
|
<TooltipTrigger className="flex cursor-pointer">
|
||||||
{children || <StackAvatars recipients={recipients} />}
|
{children || <StackAvatars recipients={recipients} />}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@ -58,7 +58,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -75,7 +75,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -92,7 +92,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -109,7 +109,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { User } from '@documenso/prisma/client';
|
import { User } from '@documenso/prisma/client';
|
||||||
@ -37,8 +38,6 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
import { useFeatureFlags } from '~/providers/feature-flag';
|
|
||||||
|
|
||||||
export type ProfileDropdownProps = {
|
export type ProfileDropdownProps = {
|
||||||
user: User;
|
user: User;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,11 +7,10 @@ import { usePathname } from 'next/navigation';
|
|||||||
|
|
||||||
import { CreditCard, Key, User } from 'lucide-react';
|
import { CreditCard, Key, User } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { useFeatureFlags } from '~/providers/feature-flag';
|
|
||||||
|
|
||||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||||
|
|||||||
@ -7,11 +7,10 @@ import { usePathname } from 'next/navigation';
|
|||||||
|
|
||||||
import { CreditCard, Key, User } from 'lucide-react';
|
import { CreditCard, Key, User } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { useFeatureFlags } from '~/providers/feature-flag';
|
|
||||||
|
|
||||||
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
|
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||||
|
|||||||
@ -17,17 +17,17 @@ const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
|
|||||||
PENDING: {
|
PENDING: {
|
||||||
label: 'Pending',
|
label: 'Pending',
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
color: 'text-blue-600',
|
color: 'text-blue-600 dark:text-blue-300',
|
||||||
},
|
},
|
||||||
COMPLETED: {
|
COMPLETED: {
|
||||||
label: 'Completed',
|
label: 'Completed',
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
color: 'text-green-500',
|
color: 'text-green-500 dark:text-green-300',
|
||||||
},
|
},
|
||||||
DRAFT: {
|
DRAFT: {
|
||||||
label: 'Draft',
|
label: 'Draft',
|
||||||
icon: File,
|
icon: File,
|
||||||
color: 'text-yellow-500',
|
color: 'text-yellow-500 dark:text-yellow-200',
|
||||||
},
|
},
|
||||||
INBOX: {
|
INBOX: {
|
||||||
label: 'Inbox',
|
label: 'Inbox',
|
||||||
|
|||||||
@ -11,10 +11,10 @@ export type AddFieldsActionInput = TAddFieldsFormSchema & {
|
|||||||
export const addFields = async ({ documentId, fields }: AddFieldsActionInput) => {
|
export const addFields = async ({ documentId, fields }: AddFieldsActionInput) => {
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
const { id: userId } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
await setFieldsForDocument({
|
await setFieldsForDocument({
|
||||||
userId,
|
userId: user.id,
|
||||||
documentId,
|
documentId,
|
||||||
fields: fields.map((field) => ({
|
fields: fields.map((field) => ({
|
||||||
id: field.nativeId,
|
id: field.nativeId,
|
||||||
|
|||||||
@ -11,10 +11,10 @@ export type AddSignersActionInput = TAddSignersFormSchema & {
|
|||||||
export const addSigners = async ({ documentId, signers }: AddSignersActionInput) => {
|
export const addSigners = async ({ documentId, signers }: AddSignersActionInput) => {
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
const { id: userId } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
await setRecipientsForDocument({
|
await setRecipientsForDocument({
|
||||||
userId,
|
userId: user.id,
|
||||||
documentId,
|
documentId,
|
||||||
recipients: signers.map((signer) => ({
|
recipients: signers.map((signer) => ({
|
||||||
id: signer.nativeId,
|
id: signer.nativeId,
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
|
|||||||
export const completeDocument = async ({ documentId, email }: CompleteDocumentActionInput) => {
|
export const completeDocument = async ({ documentId, email }: CompleteDocumentActionInput) => {
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
const { id: userId } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
if (email.message || email.subject) {
|
if (email.message || email.subject) {
|
||||||
await upsertDocumentMeta({
|
await upsertDocumentMeta({
|
||||||
@ -23,7 +23,7 @@ export const completeDocument = async ({ documentId, email }: CompleteDocumentAc
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await sendDocument({
|
return await sendDocument({
|
||||||
userId,
|
userId: user.id,
|
||||||
documentId,
|
documentId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Eye, EyeOff, Loader } from 'lucide-react';
|
import { Eye, EyeOff, Loader } from 'lucide-react';
|
||||||
@ -40,8 +38,6 @@ export type SignInFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
@ -57,36 +53,29 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
resolver: zodResolver(ZSignInFormSchema),
|
resolver: zodResolver(ZSignInFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const errorCode = searchParams?.get('error');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timeout: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
if (isErrorCode(errorCode)) {
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
description: ERROR_MESSAGES[errorCode] ?? 'An unknown error occurred',
|
|
||||||
});
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timeout) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [errorCode, toast]);
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
|
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await signIn('credentials', {
|
const result = await signIn('credentials', {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
callbackUrl: LOGIN_REDIRECT_PATH,
|
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||||
}).catch((err) => {
|
redirect: false,
|
||||||
console.error(err);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result?.error && isErrorCode(result.error)) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
description: ERROR_MESSAGES[result.error],
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result?.url) {
|
||||||
|
throw new Error('An unknown error occurred');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = result.url;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'An unknown error occurred',
|
title: 'An unknown error occurred',
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export default function NotFoundPartial({ children }: NotFoundPartialProps) {
|
|||||||
<Image
|
<Image
|
||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%]"
|
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-100 lg:scale-[100%]"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@ -1,44 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all';
|
||||||
|
|
||||||
import { getToken } from 'next-auth/jwt';
|
|
||||||
|
|
||||||
import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
|
|
||||||
|
|
||||||
import PostHogServerClient from '~/helpers/get-post-hog-server-client';
|
|
||||||
|
|
||||||
import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
runtime: 'edge',
|
runtime: 'edge',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export default handlerFeatureFlagAll;
|
||||||
* Get all the evaluated feature flags based on the current user if possible.
|
|
||||||
*/
|
|
||||||
export default async function handler(req: Request) {
|
|
||||||
const requestHeaders = Object.fromEntries(req.headers.entries());
|
|
||||||
|
|
||||||
const nextReq = new NextRequest(req, {
|
|
||||||
headers: requestHeaders,
|
|
||||||
});
|
|
||||||
|
|
||||||
const token = await getToken({ req: nextReq });
|
|
||||||
|
|
||||||
const postHog = PostHogServerClient();
|
|
||||||
|
|
||||||
// Return the local feature flags if PostHog is not enabled, true by default.
|
|
||||||
// The front end should not call this API if PostHog is not enabled to reduce network requests.
|
|
||||||
if (!postHog) {
|
|
||||||
return NextResponse.json(LOCAL_FEATURE_FLAGS);
|
|
||||||
}
|
|
||||||
|
|
||||||
const distinctId = extractDistinctUserId(token, nextReq);
|
|
||||||
|
|
||||||
const featureFlags = await postHog.getAllFlags(distinctId, mapJwtToFlagProperties(token));
|
|
||||||
|
|
||||||
const res = NextResponse.json(featureFlags);
|
|
||||||
|
|
||||||
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
|
|
||||||
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,122 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get';
|
||||||
|
|
||||||
import { JWT, getToken } from 'next-auth/jwt';
|
|
||||||
|
|
||||||
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
|
||||||
|
|
||||||
import PostHogServerClient from '~/helpers/get-post-hog-server-client';
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
runtime: 'edge',
|
runtime: 'edge',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export default handlerFeatureFlagGet;
|
||||||
* Evaluate a single feature flag based on the current user if possible.
|
|
||||||
*
|
|
||||||
* @param req The request with a query parameter `flag`. Example request URL: /api/feature-flag/get?flag=flag-name
|
|
||||||
* @returns A Response with the feature flag value.
|
|
||||||
*/
|
|
||||||
export default async function handler(req: Request) {
|
|
||||||
const { searchParams } = new URL(req.url ?? '');
|
|
||||||
const flag = searchParams.get('flag');
|
|
||||||
|
|
||||||
const requestHeaders = Object.fromEntries(req.headers.entries());
|
|
||||||
|
|
||||||
const nextReq = new NextRequest(req, {
|
|
||||||
headers: requestHeaders,
|
|
||||||
});
|
|
||||||
|
|
||||||
const token = await getToken({ req: nextReq });
|
|
||||||
|
|
||||||
if (!flag) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'Missing flag query parameter.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const postHog = PostHogServerClient();
|
|
||||||
|
|
||||||
// Return the local feature flags if PostHog is not enabled, true by default.
|
|
||||||
// The front end should not call this API if PostHog is disabled to reduce network requests.
|
|
||||||
if (!postHog) {
|
|
||||||
return NextResponse.json(LOCAL_FEATURE_FLAGS[flag] ?? true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const distinctId = extractDistinctUserId(token, nextReq);
|
|
||||||
|
|
||||||
const featureFlag = await postHog.getFeatureFlag(flag, distinctId, mapJwtToFlagProperties(token));
|
|
||||||
|
|
||||||
const res = NextResponse.json(featureFlag);
|
|
||||||
|
|
||||||
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
|
|
||||||
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map a JWT to properties which are consumed by PostHog to evaluate feature flags.
|
|
||||||
*
|
|
||||||
* @param jwt The JWT of the current user.
|
|
||||||
* @returns A map of properties which are consumed by PostHog.
|
|
||||||
*/
|
|
||||||
export const mapJwtToFlagProperties = (
|
|
||||||
jwt?: JWT | null,
|
|
||||||
): {
|
|
||||||
groups?: Record<string, string>;
|
|
||||||
personProperties?: Record<string, string>;
|
|
||||||
groupProperties?: Record<string, Record<string, string>>;
|
|
||||||
} => {
|
|
||||||
return {
|
|
||||||
personProperties: {
|
|
||||||
email: jwt?.email ?? '',
|
|
||||||
},
|
|
||||||
groupProperties: {
|
|
||||||
// Add properties to group users into different groups, such as billing plan.
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract a distinct ID from a JWT and request.
|
|
||||||
*
|
|
||||||
* Will fallback to a random ID if no ID could be extracted from either the JWT or request.
|
|
||||||
*
|
|
||||||
* @param jwt The JWT of the current user.
|
|
||||||
* @param request Request potentially containing a PostHog `distinct_id` cookie.
|
|
||||||
* @returns A distinct user ID.
|
|
||||||
*/
|
|
||||||
export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): string => {
|
|
||||||
const config = extractPostHogConfig();
|
|
||||||
|
|
||||||
const email = jwt?.email;
|
|
||||||
const userId = jwt?.id.toString();
|
|
||||||
|
|
||||||
let fallbackDistinctId = nanoid();
|
|
||||||
|
|
||||||
if (config) {
|
|
||||||
try {
|
|
||||||
const postHogCookie = JSON.parse(
|
|
||||||
request.cookies.get(`ph_${config.key}_posthog`)?.value ?? '',
|
|
||||||
);
|
|
||||||
|
|
||||||
const postHogDistinctId = postHogCookie['distinct_id'];
|
|
||||||
|
|
||||||
if (typeof postHogDistinctId === 'string') {
|
|
||||||
fallbackDistinctId = postHogDistinctId;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Do nothing.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return email ?? userId ?? fallbackDistinctId;
|
|
||||||
};
|
|
||||||
|
|||||||
138
package-lock.json
generated
@ -49,7 +49,9 @@
|
|||||||
"next-contentlayer": "^0.3.4",
|
"next-contentlayer": "^0.3.4",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
|
"posthog-js": "^1.77.3",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
"react-confetti": "^6.1.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.8.0",
|
||||||
@ -1874,6 +1876,10 @@
|
|||||||
"resolved": "packages/prisma",
|
"resolved": "packages/prisma",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@documenso/signing": {
|
||||||
|
"resolved": "packages/signing",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@documenso/tailwind-config": {
|
"node_modules/@documenso/tailwind-config": {
|
||||||
"resolved": "packages/tailwind-config",
|
"resolved": "packages/tailwind-config",
|
||||||
"link": true
|
"link": true
|
||||||
@ -2445,9 +2451,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "3.1.1",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.0.tgz",
|
||||||
"integrity": "sha512-tS16bAUkqjITNSvbJuO1x7MXbn7Oe8ZziDTJdA9mMvsoYthnOOiznOTGBYwbdlYBgU+tgpI/BtTU3paRbCuSlg==",
|
"integrity": "sha512-tgK3nWlfFLlqhqpXZmFMP3RN5E7mlbGfnM2h2ILVsW1TNGuFSod0ePW0grlIY2GAbL4pJdtmOT4HQSZsTwOiKg==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react-hook-form": "^7.0.0"
|
"react-hook-form": "^7.0.0"
|
||||||
}
|
}
|
||||||
@ -6387,9 +6393,9 @@
|
|||||||
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="
|
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="
|
||||||
},
|
},
|
||||||
"node_modules/@types/luxon": {
|
"node_modules/@types/luxon": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.2.tgz",
|
||||||
"integrity": "sha512-XOS5nBcgEeP2PpcqJHjCWhUCAzGfXIU8ILOSLpx2FhxqMW9KdxgCGXNOEKGVBfveKtIpztHzKK5vSRVLyW/NqA==",
|
"integrity": "sha512-l5cpE57br4BIjK+9BSkFBOsWtwv6J9bJpC7gdXIzZyI0vuKvNTk0wZZrkQxMGsUAuGW9+WMNWF2IJMD7br2yeQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/mdast": {
|
"node_modules/@types/mdast": {
|
||||||
@ -6421,6 +6427,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.0.tgz",
|
||||||
"integrity": "sha512-O+z53uwx64xY7D6roOi4+jApDGFg0qn6WHcxe5QeqjMaTezBO/mxdfFXIVAVVyNWKx84OmPB3L8kbVYOTeN34A=="
|
"integrity": "sha512-O+z53uwx64xY7D6roOi4+jApDGFg0qn6WHcxe5QeqjMaTezBO/mxdfFXIVAVVyNWKx84OmPB3L8kbVYOTeN34A=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node-forge": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-08scBQriFsBbm/CuBWOXRMD1RG7ydFW01EDR6vGX27nxcj6E/jGSCOLdICNd8ETwQlLFXVBVA854RX6Y7vPSrQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/nodemailer": {
|
"node_modules/@types/nodemailer": {
|
||||||
"version": "6.4.9",
|
"version": "6.4.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz",
|
||||||
@ -12868,9 +12883,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/luxon": {
|
"node_modules/luxon": {
|
||||||
"version": "3.4.0",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.2.tgz",
|
||||||
"integrity": "sha512-7eDo4Pt7aGhoCheGFIuq4Xa2fJm4ZpmldpGhjTYBNUYNCN6TIEP6v7chwwwt3KRp7YR+rghbfvjyo3V5y9hgBw==",
|
"integrity": "sha512-uBoAVCVcajsrqy3pv7eo5jEUz1oeLmCcnMv8n4AJpT5hbpN9lUssAXibNElpbLce3Mhm9dyBzwYLs9zctM/0tA==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@ -14368,11 +14383,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-forge": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.13",
|
"version": "2.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
|
||||||
"integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ=="
|
"integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/node-signpdf": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-signpdf/-/node-signpdf-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-B6fDvD8z2v0pRntQjgPO2ARqxh0pfNwfSn6YEbP7cv6xmPgcphFwcrMA3N47LztmpVbAM3vUFUslX32L6NRYDg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"node-forge": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-sql-parser": {
|
"node_modules/node-sql-parser": {
|
||||||
"version": "4.9.0",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-sql-parser/-/node-sql-parser-4.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-sql-parser/-/node-sql-parser-4.9.0.tgz",
|
||||||
@ -15239,9 +15273,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.75.3",
|
"version": "1.77.3",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.75.3.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.77.3.tgz",
|
||||||
"integrity": "sha512-q5xP4R/Tx8E6H0goZQjY+URMLATFiYXc2raHA+31aNvpBs118fPTmExa4RK6MgRZDFhBiMUBZNT6aj7dM3SyUQ==",
|
"integrity": "sha512-DKsGpBIUjQSihhGruEW8wpVCkeDxU4jz7gADdXX2jEWV6bl4WpUPxjo1ukidVDFvvc/ihCM5PQWMQrItexdpSA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fflate": "^0.4.1"
|
"fflate": "^0.4.1"
|
||||||
}
|
}
|
||||||
@ -15755,6 +15789,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-confetti": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw==",
|
||||||
|
"dependencies": {
|
||||||
|
"tween-functions": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.3.0 || ^17.0.1 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-day-picker": {
|
"node_modules/react-day-picker": {
|
||||||
"version": "8.8.0",
|
"version": "8.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.8.0.tgz",
|
||||||
@ -16285,9 +16333,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.45.2",
|
"version": "7.45.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.4.tgz",
|
||||||
"integrity": "sha512-9s45OdTaKN+4NSTbXVqeDITd/nwIg++nxJGL8+OD5uf1DxvhsXQ641kaYHk5K28cpIOTYm71O/fYk7rFaygb3A==",
|
"integrity": "sha512-HGDV1JOOBPZj10LB3+OZgfDBTn+IeEsNOKiq/cxbQAIbKaiJUe/KV8DBUzsx0Gx/7IG/orWqRRm736JwOfUSWQ==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.22.0"
|
"node": ">=12.22.0"
|
||||||
},
|
},
|
||||||
@ -16910,6 +16958,29 @@
|
|||||||
"node": ">=0.10.5"
|
"node": ">=0.10.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resend": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resend/-/resend-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-it8TIDVT+/gAiJsUlv2tdHuvzwCCv4Zwu+udDqIm/dIuByQwe68TtFDcPccxqpSVVrNCBxxXLzsdT1tsV+P3GA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-email/render": "0.0.7",
|
||||||
|
"type-fest": "3.13.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/resend/node_modules/type-fest": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-Gur3yQGM9qiLNs0KPP7LPgeRbio2QTt4xXouobMCarR0/wyW3F+F/+OWwshg3NG0Adon7uQfSZBpB46NfhoF1A==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.2",
|
"version": "1.22.2",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz",
|
||||||
@ -18919,6 +18990,11 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tween-functions": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tween-functions/-/tween-functions-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA=="
|
||||||
|
},
|
||||||
"node_modules/typanion": {
|
"node_modules/typanion": {
|
||||||
"version": "3.13.0",
|
"version": "3.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/typanion/-/typanion-3.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/typanion/-/typanion-3.13.0.tgz",
|
||||||
@ -19720,7 +19796,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-email/components": "^0.0.7",
|
"@react-email/components": "^0.0.7",
|
||||||
"nodemailer": "^6.9.3",
|
"nodemailer": "^6.9.3",
|
||||||
"react-email": "^1.9.4"
|
"react-email": "^1.9.4",
|
||||||
|
"resend": "^1.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
@ -19756,6 +19833,7 @@
|
|||||||
"@aws-sdk/signature-v4-crt": "^3.410.0",
|
"@aws-sdk/signature-v4-crt": "^3.410.0",
|
||||||
"@documenso/email": "*",
|
"@documenso/email": "*",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
|
"@documenso/signing": "*",
|
||||||
"@next-auth/prisma-adapter": "1.0.7",
|
"@next-auth/prisma-adapter": "1.0.7",
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"@scure/base": "^1.1.3",
|
"@scure/base": "^1.1.3",
|
||||||
@ -19806,6 +19884,26 @@
|
|||||||
"typescript": "^5.1.6"
|
"typescript": "^5.1.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/signing": {
|
||||||
|
"name": "@documenso/signing",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "AGPLv3",
|
||||||
|
"dependencies": {
|
||||||
|
"@documenso/tsconfig": "*",
|
||||||
|
"node-forge": "^1.3.1",
|
||||||
|
"node-signpdf": "^2.0.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"ts-pattern": "^5.0.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node-forge": "^1.3.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/signing/node_modules/ts-pattern": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-tL0w8U/pgaacOmkb9fRlYzWEUDCfVjjv9dD4wHTgZ61MjhuMt46VNWTG747NqW6vRzoWIKABVhFSOJ82FvXrfA=="
|
||||||
|
},
|
||||||
"packages/tailwind-config": {
|
"packages/tailwind-config": {
|
||||||
"name": "@documenso/tailwind-config",
|
"name": "@documenso/tailwind-config",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@ -19847,6 +19945,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
|
"@hookform/resolvers": "^3.3.0",
|
||||||
"@radix-ui/react-accordion": "^1.1.1",
|
"@radix-ui/react-accordion": "^1.1.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.0.3",
|
"@radix-ui/react-alert-dialog": "^1.0.3",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.0.2",
|
"@radix-ui/react-aspect-ratio": "^1.0.2",
|
||||||
@ -19867,27 +19966,32 @@
|
|||||||
"@radix-ui/react-select": "^1.2.1",
|
"@radix-ui/react-select": "^1.2.1",
|
||||||
"@radix-ui/react-separator": "^1.0.2",
|
"@radix-ui/react-separator": "^1.0.2",
|
||||||
"@radix-ui/react-slider": "^1.1.1",
|
"@radix-ui/react-slider": "^1.1.1",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-switch": "^1.0.2",
|
"@radix-ui/react-switch": "^1.0.2",
|
||||||
"@radix-ui/react-tabs": "^1.0.3",
|
"@radix-ui/react-tabs": "^1.0.3",
|
||||||
"@radix-ui/react-toast": "^1.1.3",
|
"@radix-ui/react-toast": "^1.1.3",
|
||||||
"@radix-ui/react-toggle": "^1.0.2",
|
"@radix-ui/react-toggle": "^1.0.2",
|
||||||
"@radix-ui/react-tooltip": "^1.0.5",
|
"@radix-ui/react-tooltip": "^1.0.6",
|
||||||
"@tanstack/react-table": "^8.9.1",
|
"@tanstack/react-table": "^8.9.1",
|
||||||
"class-variance-authority": "^0.6.0",
|
"class-variance-authority": "^0.6.0",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
|
"luxon": "^3.4.2",
|
||||||
"next": "13.4.19",
|
"next": "13.4.19",
|
||||||
"pdfjs-dist": "3.6.172",
|
"pdfjs-dist": "3.6.172",
|
||||||
"react-day-picker": "^8.7.1",
|
"react-day-picker": "^8.7.1",
|
||||||
|
"react-hook-form": "^7.45.4",
|
||||||
"react-pdf": "^7.3.3",
|
"react-pdf": "^7.3.3",
|
||||||
|
"react-rnd": "^10.4.1",
|
||||||
"tailwind-merge": "^1.12.0",
|
"tailwind-merge": "^1.12.0",
|
||||||
"tailwindcss-animate": "^1.0.5"
|
"tailwindcss-animate": "^1.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
"@documenso/tsconfig": "*",
|
"@documenso/tsconfig": "*",
|
||||||
|
"@types/luxon": "^3.3.2",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7",
|
"@types/react-dom": "18.2.7",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { createTransport } from 'nodemailer';
|
import { createTransport } from 'nodemailer';
|
||||||
|
|
||||||
import { MailChannelsTransport } from './transports/mailchannels';
|
import { MailChannelsTransport } from './transports/mailchannels';
|
||||||
|
import { ResendTransport } from './transports/resend';
|
||||||
|
|
||||||
const getTransport = () => {
|
const getTransport = () => {
|
||||||
const transport = process.env.NEXT_PRIVATE_SMTP_TRANSPORT ?? 'smtp-auth';
|
const transport = process.env.NEXT_PRIVATE_SMTP_TRANSPORT ?? 'smtp-auth';
|
||||||
@ -14,6 +15,14 @@ const getTransport = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (transport === 'resend') {
|
||||||
|
return createTransport(
|
||||||
|
ResendTransport.makeTransport({
|
||||||
|
apiKey: process.env.NEXT_PRIVATE_RESEND_API_KEY || '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (transport === 'smtp-api') {
|
if (transport === 'smtp-api') {
|
||||||
if (!process.env.NEXT_PRIVATE_SMTP_HOST || !process.env.NEXT_PRIVATE_SMTP_APIKEY) {
|
if (!process.env.NEXT_PRIVATE_SMTP_HOST || !process.env.NEXT_PRIVATE_SMTP_APIKEY) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||