diff --git a/.env.example b/.env.example index 4919f0053..c47995207 100644 --- a/.env.example +++ b/.env.example @@ -103,6 +103,12 @@ NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID= +# [[BACKGROUND JOBS]] +NEXT_PRIVATE_JOBS_PROVIDER="local" +NEXT_PRIVATE_TRIGGER_API_KEY= +NEXT_PRIVATE_TRIGGER_API_URL= +NEXT_PRIVATE_INNGEST_EVENT_KEY= + # [[FEATURES]] # OPTIONAL: Leave blank to disable PostHog and feature flags. NEXT_PUBLIC_POSTHOG_KEY="" diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 8113ada52..d6a275a59 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -4,6 +4,7 @@ module.exports = { extends: ['@documenso/eslint-config'], rules: { '@next/next/no-img-element': 'off', + 'no-unreachable': 'error', }, settings: { next: { diff --git a/.vscode/settings.json b/.vscode/settings.json index 1fc8321db..f5542fbb5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,11 +5,19 @@ "editor.codeActionsOnSave": { "source.fixAll": "explicit" }, - "eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"], + "eslint.validate": [ + "typescript", + "typescriptreact", + "javascript", + "javascriptreact" + ], "javascript.preferences.importModuleSpecifier": "non-relative", "javascript.preferences.useAliasesForRenames": false, "typescript.enablePromptUseWorkspaceTsdk": true, "files.eol": "\n", "editor.tabSize": 2, - "editor.insertSpaces": true -} + "editor.insertSpaces": true, + "[prisma]": { + "editor.defaultFormatter": "Prisma.prisma" + }, +} \ No newline at end of file diff --git a/README.md b/README.md index d6a5053f4..16738923c 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,22 @@ Contact us if you are interested in our Enterprise plan for large organizations Book us with Cal.com ## Tech Stack +

+ TypeScript + NextJS + Made with Prisma + Tailwind CSS + + + + + +

+ - [Typescript](https://www.typescriptlang.org/) - Language - [Next.js](https://nextjs.org/) - Framework -- [Prisma](https://www.prisma.io/) - ORM +- [Prisma](https://www.prisma.io/) - ORM - [Tailwind](https://tailwindcss.com/) - CSS - [shadcn/ui](https://ui.shadcn.com/) - Component Library - [NextAuth.js](https://next-auth.js.org/) - Authentication diff --git a/apps/marketing/content/blog/announcing-direct-links.mdx b/apps/marketing/content/blog/announcing-direct-links.mdx index ef75e9ae4..b52050ede 100644 --- a/apps/marketing/content/blog/announcing-direct-links.mdx +++ b/apps/marketing/content/blog/announcing-direct-links.mdx @@ -4,7 +4,7 @@ description: Today, we are launching direct links to templates, a new and async authorName: 'Timur Ercan' authorImage: '/blog/blog-author-timur.jpeg' authorRole: 'Co-Founder' -date: 2024-06-06 +date: 2024-06-17 tags: - Announcement - Direct Links @@ -16,7 +16,7 @@ tags: src="/blog/direct-links.png" width="1400" height="884" - alt="Documenso announcement blog banner" + alt="Direct Links in Templats List View" />
Direct Template Links - Async signing, anytime.
@@ -25,6 +25,7 @@ tags: > TLDR; We are launching direct links to templates. With direct links, a document is created from a template every time anyone signs the link. Links can be public. ## Sync or Async? + > Quick refresher on Sync vs. Async: Sync means everyone has to wait for me until they can continue their work. Async means everyone can and does their work at the time that fits best. Digital signing has become almost as normalized as email when doing business. While not 100% of companies are onboarded on digital signatures yet, hardly anyone is surprised when receiving a link to sign something digitally. As we got used to the user experience of sending emails, we also got used to the experience of sending document signature requests, with all the downsides: @@ -34,6 +35,7 @@ Digital signing has become almost as normalized as email when doing business. Wh - I need to monitor the requests I started for completion: "I sent you a link yesterday; please check it out." ## Introducing Direct Links + Today, we are introducing a new paradigm to signing: Async Direct Signing Links. Direct links are attached to a template and can be used anytime by anyone using the link. You set up the signature experience and flow once using all existing template mechanisms and you are done. You can provide anyone with the link so they can sign whenever they need to. You can even post the link publicly if you want to maximize its reach, i.e. for sales contracts. - ## Embrace Async + So, how does this help anyone? You may still need to send a signature request to people, but in the cases you don't, you are not forced to anymore. Need an NDA? Check out our standing NDA link. A customer needs an updated Form W-9? Just use the company W-9 Link; it always has the most up-to-date form. You can even go as far as publicly posting a link to a software development or design contract any potential customer can sign anytime. Can they talk to you first? Sure, but if they don't need to or already have to, they go straight to the link. The process of actively sending has gotten us used to using a sync paradigm (I send, you receive and sign, and I get the result), whereas an async one (you sign whenever it suits you, and I become active only then, if at all) is way better suited. Adding more approval and signature steps makes sure you still control the outcome, but the process becomes a lot more efficient. For example, you can grab your own copy of the early adopter's pledge here if you missed it: [documen.so/pledge](https://documen.so/pledge). -> Take a minute to think about every signing request you send and whether they really require you to be part of the transaction. Could they be outsourced to the recipient, only to be reviewed once their part is done? - - +> Take a minute to think about every signing request you send and whether they really require you to be part of the transaction. Could they be outsourced to the recipient and only reviewed once their part is done? ## Coming Soon: Profiles + The best place to put your public links will be your **Documenso profile**, which is also close to launching. We want to get a feel for how links are used and move on to profiles shortly after. Want to try out direct links? Grab a free account here to get started: [documen.so/free](https://documen.so/free). -As always, we want to hear from you on [Twitter / X](https://twitter.com/eltimuro) (DMs are open) or [Discord](https://documen.so/discord) if you have any questions, ideas or comments. +As always, we want to hear from you on [Twitter / X](https://twitter.com/eltimuro) (DMs are open) or [Discord](https://documen.so/discord) if you have any questions, ideas, or comments. Best from Hamburg\ -Timur \ No newline at end of file +Timur diff --git a/apps/marketing/content/blog/how-documenso-enhances-contract-management-for-freelancers-helping-them-close-more-clients-efficiently.mdx b/apps/marketing/content/blog/how-documenso-enhances-contract-management-for-freelancers-helping-them-close-more-clients-efficiently.mdx new file mode 100644 index 000000000..fc837932a --- /dev/null +++ b/apps/marketing/content/blog/how-documenso-enhances-contract-management-for-freelancers-helping-them-close-more-clients-efficiently.mdx @@ -0,0 +1,103 @@ +--- +title: How Documenso Enhances Contract Management for Freelancers, Helping Them Close More Clients Efficiently +description: Making it easy for the customer to sign the contract after they say yes is critical. Let take a look how Documenso can help. +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2024-06-20 +tags: + - Freelancer + - Proposal + - Productivity +--- + +## Yes to Yes + +> [Check out Part 1](https://documen.so/freelance-proposal) to learn about signing freelance proposals with Documenso and getting your first yes + +A basic rule of sales is going from "yes to yes”. Outlining the main points of working together in a proposal is a good way to get to your first yes since it reduces details and focuses on the main points of the work at hand. After being on the same page about the work and getting the first yes, it's time to draw up a formal contract. While agreeing to the proposal has some weight as well, the legal contract formalizes the commitments of both sides in an enforceable way. Having clear legal terms on payments, unexpected cases, and even dissolving the partnership helps both parties to feel assured about what to expect. + +### **Digital Signatures for the Win** + +Digitally signing documents accelerates contract closure, enhancing both speed and security. Parties can review and sign documents within minutes, eliminating the days required for manual signatures or even weeks with traditional mail. Beyond these efficiency gains, digital signatures boost trust by making the process secure and auditable. Once signed, digital documents are immutable, and every step is logged. + +Documenso simplifies this process, allowing you to send contracts effortlessly. As an open-source solution, our product's integrity and security are verifiable by anyone, which is why thousands of users rely on Documenso for their signing needs. Discover more at [https://documen.so/open](https://documen.so/open). + +## Preparing the Contract + +As a freelancer, obtaining a contract template ensures you have a standardized and professional agreement ready for new clients, helping to protect your interests and clarify project terms. While there are many good templates out there, be sure to verify that they fit your case since contracts are often very specific to a certain case. Always consider having your contract checked by a legal professional if it's a high-value transaction. + +Here is a quick checklist of what your contract should include: + +### Checklist + +- Names and Addresses of you and your client +- Scope of Work to be performed, deadlines and deliverables +- Payment Terms, Payment Schedules, and Pricing +- A clear timeline +- Provisions for unexpected extra work +- Intellectual Property Rights Provisions +- Confidentiality and Non-Disclosure Agreements, if needed +- Termination Clauses: Condition and terms when the contract can be terminated, including notice period and compensation +- Indemnity and Liability +- Dispute Resolution +- Provisions ensuring changes can only be made in writing +- Completeness Agreement: Both parties state this is the full extent of the agreement +- Severability Clause ensuring minor errors will not endanger the whole contract +- The signees with name, role, and date + +## Getting the Signature + +Once you have your contract ready, you can upload it and add recipients and signature fields. To add a more personal touch, consider adding a personal message to the signature request. + +
+ + +
+ Copy recipient links to send them for a personal touch manually. +
+
+ +You can also copy the link for each recipient after sending it and send it via another channel e.g. WhatsApp with a personal message. To further customize the experience, you can define a redirect when your customer signs the contract to redirect them to a Cal.com Link to get started, a Thank You Page, or a Form. + +
+ + +
+ Redirect after Signing for a more personal experience. +
+
+ +The more you add to the workflow, the more important it is to keep up to date with the process. Using Zapier, you can add a variety of notifications, from email to Discord messages, to keep a good overview and respond quickly. It's not just about getting the signatures; it's about creating the workflow that provides the best experience for you and your customers. + +
+ + +
+ Trigger any kind of notification with[Zapier](https://documen.so/zapier) +
+
+ +### Conclusion + +Sending a contract to clients using Documenso makes the process fast and easy. Seeing if your contract was signed or even read helps you understand where you are in the process. You can use the [Documenso Free Plan](https://app.documenso.com/signup?utm_source=blog-freelancer-contract) to send 5 contracts per month. Digital signing in 2024 is the best practice for professionals seeking the most efficient way to get business done. + +Let us know what you think and what we can improve. Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :) + +Best from Hamburg\ +Timur diff --git a/apps/marketing/content/blog/how-documenso-help-freelancers-close-more-clients-efficiently.mdx b/apps/marketing/content/blog/how-documenso-help-freelancers-close-more-clients-efficiently.mdx new file mode 100644 index 000000000..7d7265842 --- /dev/null +++ b/apps/marketing/content/blog/how-documenso-help-freelancers-close-more-clients-efficiently.mdx @@ -0,0 +1,76 @@ +--- +title: How Documenso Helps Freelancers Close More Clients Efficiently +description: Reducing friction when sending proposals is critical to closing new clients. By using Documenso, freelancers can save time, enhance client interactions, and ultimately close more deals efficiently. +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2024-06-14 +tags: + - Freelancer + - Proposal + - Productivity +--- + +Getting new clients, or maybe even your first client, to sign with you is at the very core of freelance work. Whether you develop software, create designs, or market products, it all starts with a signature from you and your future customer. Closing a customer usually means agreeing on a proposal first and then signing a formal agreement afterward. Signing proposals and contracts is fast, easy, and painless with Documenso, so let's take a look. + +## Understanding Proposal and Contracts + +### 1. Initial Proposal + +> Agreeing on what needs to be done and terms for payment + +A proposal will include the scope of the work (what does the customer want done?), desired deliverables (documents, code, features, videos, etc.), timelines, payment terms (one-time, monthly, per hour), and prices (e.g. $60/ hour, $5k one-time). A proposal is important for both sides to be clear about the goal and the terms that apply. Customers usually decide based on the proposal if your offer is what they want. + +### 2. Formal Contract + +> After the client signs the proposal, a contract can be signed, formalizing the agreement and adding detailed legal terms. + +Once the terms are agreed upon, a more formal document should specify the terms of working together, especially legal details such as confidentiality, indemnification, governing law, etc. The contract provides clarity on legal details for both sides and formalizes their claims. + +Sending one or even multiple documents to a potential client and having them send them back signed (in the worst case, they send it via actual mail) is time-consuming for both sides. It also introduces friction at a time when making it as easy as possible for your potential client to say yes should be your number one goal. + +### **Digital Signatures for the Win** + +Signing documents digitally makes closing proposals and contracts faster and more secure. Each party can review and sign the documents in minutes instead of days (inserting the signatures manually via PDF editor) or even weeks (using conventional mail). Apart from the efficiency gains, signing digitally also increases trust by making the process more secure and auditable. Digitally signed documents can’t be changed after the fact, and every step of the process is logged. + +Documenso lets you reap these benefits by sending proposals and contracts with minimal effort. Being open source, the whole world can verify our product and how we deliver on these promises, which is why thousands of users already trust Documenso for their signing needs: [https://documen.so/open](https://documen.so/open). + +## Preparing the Proposal + +If you already have a proposal template, create a new version for your client and export it to PDF. If your tool doesn’t support that, your system's “PDF printer” lets you create a PDF from almost any tool by using the print function. If you do not have a template yet, you can find a lot of content and guides on the matter through a quick Google search. Here is a quick checklist of what your proposal should cover: + +- A clear and concise title +- Your contact information +- Date of the proposal/ validity period +- Experience, qualifications, and prior relevant projects +- A summary of the project +- A detailed description of the project +- Goals, outcomes, and deliverables +- Tasks and activities to achieve the goals and outcomes +- A timeline with milestones and deadlines +- Pricing terms and payment schedule +- Summary of major terms for the coming contract + +## Sending the Proposal + +If you don’t have a Documenso Account yet, you can [create one for free](https://documen.so/signup?utm_source=blog-freelancer-proposal). Once you sign up, you can upload your proposal PDF by simply dragging it into the upload area. Add your potential client as a recipient, add a signature field, and you are done! You can track the status of your proposal simply by clicking the Document in the overview. Documenso will also notify you once the proposal is signed. + + + +### Conclusion + +Sending a proposal to potential clients using Documenso makes getting to the first “yes” fast and easy. Seeing if your proposal was signed or even read helps you to get a feel for where you are in the process. You can use the [Documenso Free Plan](https://app.documenso.com/signup?utm_source=blog-freelancer-proposal) to send 5 proposals per month. Digital Signing in 2024 is the best practice for all professionals looking for the most efficient way to get business done. + +> [Check out Part 2](https://documen.so/freelance-contract) to learn about signing freelance contracts with Documenso. + +Let us know what you think and what we can improve. Which field types are you missing? Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :) + +Best from Hamburg\ +Timur diff --git a/apps/marketing/content/blog/signing-an-nda-faster-with-documenso.mdx b/apps/marketing/content/blog/signing-an-nda-faster-with-documenso.mdx new file mode 100644 index 000000000..39346b784 --- /dev/null +++ b/apps/marketing/content/blog/signing-an-nda-faster-with-documenso.mdx @@ -0,0 +1,88 @@ +--- +title: How to Sign an NDA online (fast) +description: Signing an NDA with Documenso direct links is amazingly fast. Let’s look at how to make it even faster. +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2024-06-27 +tags: + - NDA + - Direct Links +--- + +
+ + +
A generic document saying "NDA" to underline this article is about NDAs.
+
+ +> TLDR; Documenso makes sending NDAs faster, faster with templates, and even still faster using direct links. + +## What is an NDA? +An NDA, or non-disclosure agreement, is a legal contract that establishes a confidential relationship. The parties involved agree not to disclose information covered by the agreement. NDAs are often used to protect sensitive information or trade secrets and to ensure that such information isn't made public by the recipient without permission. They are commonly used in business settings, such as during negotiations or when new employees are hired who will have access to proprietary information. + +## Do I need an NDA? +> Disclaimer: This is not legal advice, and the most important legal questions are often ultra-case-specific and should be discussed with a legal professional + + +There is a solid amount of debate around the question of whether an NDA actually protects anyone and is worth the friction. Investors scoff at the idea of a startup requiring them to sign an NDA before disclosing their "billion dollar idea" as they see hundreds of them and are aware that without proper execution, there is nothing to protect. + +In another classical example, a big company and a small company e.g. a startup, sign an NDA before going into detail for partnership talks. While this seems prudent, given the resource asymmetry, the startup probably won't be able to litigate against breach of contract successfully. If Microsoft (not saying they do) breaks your NDA, you will hardly sue them into a settlement. + +That being said, as with most contracts, NDAs can be useful if both parties keep the spirit of the agreement. In this case, detailing in writing what can and cannot be disclosed is good for managing expectations and building trust. NDAs are also common practice in merger and acquisition projects and are often part of hiring critical roles within a company. + +### Level 1: Basic Signing +If you need to sign an NDA, signing it with Documenso is incredibly fast already. Let's take a look at how to make it even faster. Simply uploading and sending is the most straightforward way to get this done. It works like this: + +- Upload the NDA PDF template +- Add the recipients +- place the signature fields +- Hit send +- Sign the NDA while or after waiting for your counterpart to sign + + + +### Level 2: Using Templates +If you have to sign the same NDA multiple times with different people, you can create a template to save time. Creating a template is just as easy as creating a document, just skipping the sending step. After creating the template for your NDA, you can create a signable document with just 1 click. Simply fill in the recipient email, and you are done. +> Pro Tip: Check "Send Document" to immediately send it after filling out the recipient if you are familiar with the template. + + +
+ You can send out templates without even going through the full flow. +
+ +### Level 3: Using a Direct Link +Using a pre-defined template is pretty fast, but can we make it even faster? Yes, we can! By adding a direct link to your NDA template and publishing it (internally or even externally). A [Direct Link](http://documenso.com/blog/announcing-direct-links) lets people sign your NDA without you lifting a finger. Everyone with access to the link can sign it at any time, making discussions of who sends what when a thing of the past. You can use Direct links with a pre-signed template for maximum convenience or with a second signer/ approver from your side to keep control over the process. + +You can try it here and [sign a demo NDA](https://documen.so/demo-nda) with me. + +> Pro Tip: Use [Zapier](https://documen.so/zapier) to get notified of that platform of your choice as soon as someone signs your link. + +## Conclusion +Signing NDAs is not always effective, but it can be necessary, so be sure to use a tool to make it easy and fast. Documenso is a great DocuSign alternative that helps you get it done. If you need to get an NDA out today, you can use the [Documenso Free plan](https://documen.so/free), which gives you 5 signatures per month and 3 Direct Link templates. + +As always, we want to hear from you on [Twitter / X](https://twitter.com/eltimuro) (DMs are open) or [Discord](https://documen.so/discord) if you have any questions, ideas, or comments. + +Best from Hamburg\ +Timur \ No newline at end of file diff --git a/apps/marketing/content/blog/sunsetting-early-adopters.mdx b/apps/marketing/content/blog/sunsetting-early-adopters.mdx new file mode 100644 index 000000000..8f4d24111 --- /dev/null +++ b/apps/marketing/content/blog/sunsetting-early-adopters.mdx @@ -0,0 +1,52 @@ +--- +title: Sunsetting the Early Adopters Plan +description: We reached or Early Adopter cap and not transition to our regular pricing 🎉 +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2024-06-12 +tags: + - Early Adopters + - Pricing + - Open Startup +--- + +
+ + +
+ "Being early is, uh, good." -Unknown +
+
+ +> TLDR; The Early Adopters Plan ended, and we have a new pricing. If you are an Early Adopter, reach out for a Discord community badge 🏅 + +# The End of the Beginning + +12 months, 13 releases, and 344 merged pull requests after announcing the Early Adopter plan in our first-ever launch week, and we hit the cap of 100. Documenso has changed and grown a lot since then. For us, this is a great milestone towards our broader mission of bringing open signing to the world since now we are joined by a community consisting of contributors and early customers all around the world. + +# The New Plans + +Starting today, we are sunsetting the Early Adopter Plan in favor of our new, more nuanced pricing model. The Early Adopter plan will succeeded by the **Individual plan**, which is still priced at $30/mo. The Individual plans will still include unlimited signatures and recipients since this aligns with our core belief of empowering our users wherever possible. If you managed to grab an Early Adopter plan, reach out on X or Discord to receive a special community badge. Early Adopters are meant to get preferential treatment where possible. + +Previously soft-launched as part of Early Adopters, we are officially introducing the **Team Plan** to our pricing for customers requiring multi-user accounts. Priced at $50/ mo. for 5 users, this plan offers unlimited signature volume as well. Additional users can be added for $10/mo. as needed. We have carefully crafted the billing of teams to ensure that dynamic changes are accurately reflected at the end of each billing cycle, providing you with a fair-value pricing structure. + +Our **Free Plan** stays unchanged, offering coverage to casual users and an easy way to try out Documenso or start developing. + +Check out our [new pricing page here](https://documen.so/pricing). We also updated our [open page](https://documen.so/open) to reflect the end of Early Adopters. The metric now counts active subscriptions from Individuals and Teams. + +# API Access + +All plans include access to the API as per our philosophy, making Documenso an open platform, and allowing everyone to build on it, no matter how big or small. Besides the Free Plan's 5 signatures per month limit, the API does not have access restrictions. Even the free plan can keep using the API after using its signature volume for non-signing operations like reading, editing, and even creating documents. Since the individual plan technically allows for running a Fortune 500 company for $30/ mo., plan we are adding a fair use clause here: You are free to use the API "a lot" if you are a big organization trying to stay on the Individual Plan we will ask to have a word about upgrading (which might make sense anyway considering your requirements). Fair use excludes Early Adopters, which we consider limitless by any measure. If you need clarification on whether your case is covered under fair use, you can contact us on Discord or support@documenso.com. It's probably fine, though. + +We also have a lot in the pipeline, and we are excited to share everything with you soon. A Big Shoutout to all Early Adopters. We salute you, and you will receive the preferred treatment where possible. + +If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord). + +Best from Hamburg\ +Timur diff --git a/apps/marketing/content/changelog.mdx b/apps/marketing/content/changelog.mdx new file mode 100644 index 000000000..24824f299 --- /dev/null +++ b/apps/marketing/content/changelog.mdx @@ -0,0 +1,55 @@ +--- +title: Changelog - Documenso +--- + +# Changelog + +Check out what's new in the latest major version and read what we think about it. You can find our releases on GitHub for more technical details [here](https://github.com/documenso/documenso/releases). You can find our [release candidates here](https://github.com/documenso/documenso/tags). + +--- + +## v1.5.5 (latest) + +### Released 6th May 2024 + +> This release contains [20 fixes](https://github.com/documenso/documenso/releases/tag/v1.5.5) + +### ✅ Show Completed Fields + +Fields completed by other recipients are now visible to everyone to communicate the state of the document better and allow users an informed decision on what they are signing. + +### ⬇️ Download Completed Documents via API + +Completed documents can now be downloaded via the API using this new endpoint: + +**GET /API/V1//DOCUMENTS/\{ID\}/DOWNLOAD** + +Check out the full Open API docs here: [https://documen.so/openapi](https://documen.so/openapi) + +### ➕ Adding Yourself as a Signer + +Adding yourself as a signer is now just one click away. + +--- + +## v1.5.4 + +### Released 11th April 2024 + +> This release contains [21 fixes](https://github.com/documenso/documenso/releases/tag/v1.5.4) + +#### 🔑 Passkeys + +To improve security and usability for high-security setups, we added passkeys with this release. Passkeys can now be used to log in or re-authenticate each signature for high-compliance cases. + +#### 📄 Signing Certificate & Audit Log + +On the security/ compliance side, we also added Signing Certificates and Audit Logs. Every signed document now has a certificate attached, showing technical details of the signature to improve transparency and security. Further, every action on a document from creation to completion is now logged in the audit log to guarantee the integrity of the process. + +#### 🔏🦀 @documenso/pdf-sign + +We are pretty hyped about this one: Since version 0.9, we relied on https://github.com/vbuch/node-signpdf to add the digital signatures to our documents. Since signing is at the heart of Documenso, we created our own rust-based library for signing. As of 1.5.4, Documenso's signing runs on @documenso/pdf-sign. The library offers a better architecture to enable signing with private keys that are not stored locally (e.g. via HSM). We are in the process of cleaning up the library to open source it like the rest of Documenso 🌱 The library will also help us to offer Long Term Validation (LTV) for signatures soon. While we are currently limited to signing with PKCS7-B, eventually, we plan to support all common signing standards like PAdES, CAdES, and XAdES. + +--- + +´ diff --git a/apps/marketing/package.json b/apps/marketing/package.json index eb36b71bb..acda7e0e7 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -21,6 +21,9 @@ "@hookform/resolvers": "^3.1.0", "@openstatus/react": "^0.0.3", "contentlayer": "^0.3.4", + "embla-carousel": "^8.1.3", + "embla-carousel-autoplay": "^8.1.3", + "embla-carousel-react": "^8.1.3", "framer-motion": "^10.12.8", "lucide-react": "^0.279.0", "luxon": "^3.4.0", @@ -38,7 +41,7 @@ "react-hook-form": "^7.43.9", "react-icons": "^4.11.0", "recharts": "^2.7.2", - "sharp": "^0.33.1", + "sharp": "0.32.6", "typescript": "5.2.2", "zod": "^3.22.4" }, @@ -55,4 +58,4 @@ "next": "$next" } } -} +} \ No newline at end of file diff --git a/apps/marketing/public/blog/l1.png b/apps/marketing/public/blog/l1.png new file mode 100644 index 000000000..8ac68dc3a Binary files /dev/null and b/apps/marketing/public/blog/l1.png differ diff --git a/apps/marketing/public/blog/l2.png b/apps/marketing/public/blog/l2.png new file mode 100644 index 000000000..13ef6fec8 Binary files /dev/null and b/apps/marketing/public/blog/l2.png differ diff --git a/apps/marketing/public/blog/l3.png b/apps/marketing/public/blog/l3.png new file mode 100644 index 000000000..1df9c8f81 Binary files /dev/null and b/apps/marketing/public/blog/l3.png differ diff --git a/apps/marketing/public/blog/nda.jpg b/apps/marketing/public/blog/nda.jpg new file mode 100644 index 000000000..1a4b9dd57 Binary files /dev/null and b/apps/marketing/public/blog/nda.jpg differ diff --git a/apps/marketing/public/blog/send-documents.png b/apps/marketing/public/blog/send-documents.png new file mode 100644 index 000000000..f65462e1b Binary files /dev/null and b/apps/marketing/public/blog/send-documents.png differ diff --git a/apps/marketing/public/blog/sunset.jpg b/apps/marketing/public/blog/sunset.jpg new file mode 100644 index 000000000..f3cf8b42e Binary files /dev/null and b/apps/marketing/public/blog/sunset.jpg differ diff --git a/apps/marketing/public/signing.mp4 b/apps/marketing/public/signing.mp4 new file mode 100644 index 000000000..1687873a7 Binary files /dev/null and b/apps/marketing/public/signing.mp4 differ diff --git a/apps/marketing/src/app/(marketing)/open/data.ts b/apps/marketing/src/app/(marketing)/open/data.ts index a3f314d9f..3b109ea74 100644 --- a/apps/marketing/src/app/(marketing)/open/data.ts +++ b/apps/marketing/src/app/(marketing)/open/data.ts @@ -47,14 +47,6 @@ export const TEAM_MEMBERS = [ engagement: 'Full-Time', joinDate: 'October 9th, 2023', }, - { - name: 'Adithya Krishna', - role: 'Software Engineer - II', - salary: '-', - location: 'India', - engagement: 'Full-Time', - joinDate: 'December 1st, 2023', - }, ]; export const FUNDING_RAISED = [ diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index 31990519e..d70b4253d 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -247,8 +247,8 @@ export default async function OpenPage() { data={EARLY_ADOPTERS_DATA} metricKey="earlyAdopters" - title="Early Adopters" - label="Early Adopters" + title="Total Customers" + label="Total Customers" className="col-span-12 lg:col-span-6" extraInfo={} /> diff --git a/apps/marketing/src/app/(marketing)/open/tooltip.tsx b/apps/marketing/src/app/(marketing)/open/tooltip.tsx index d077e7d35..2a77f45a1 100644 --- a/apps/marketing/src/app/(marketing)/open/tooltip.tsx +++ b/apps/marketing/src/app/(marketing)/open/tooltip.tsx @@ -29,7 +29,7 @@ export function OpenPageTooltip() { -

Active Subscriptions.

+

Customers with an Active Subscriptions.

diff --git a/apps/marketing/src/app/(marketing)/pricing/page.tsx b/apps/marketing/src/app/(marketing)/pricing/page.tsx index b98460d39..f4103df9c 100644 --- a/apps/marketing/src/app/(marketing)/pricing/page.tsx +++ b/apps/marketing/src/app/(marketing)/pricing/page.tsx @@ -9,6 +9,7 @@ import { } from '@documenso/ui/primitives/accordion'; import { Button } from '@documenso/ui/primitives/button'; +import { Enterprise } from '~/components/(marketing)/enterprise'; import { PricingTable } from '~/components/(marketing)/pricing-table'; export const metadata: Metadata = { @@ -42,6 +43,10 @@ export default function PricingPage() { +
+ +
+

None of these work for you? Try self-hosting! diff --git a/apps/marketing/src/components/(marketing)/callout.tsx b/apps/marketing/src/components/(marketing)/callout.tsx index dfd358c71..5e786abb4 100644 --- a/apps/marketing/src/components/(marketing)/callout.tsx +++ b/apps/marketing/src/components/(marketing)/callout.tsx @@ -34,17 +34,18 @@ export const Callout = ({ starCount }: CalloutProps) => { return (
- + + + { + const slides = SLIDES; + const [_isPlaying, setIsPlaying] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const [progress, setProgress] = useState(0); + const videoRefs = useRef<(HTMLVideoElement | null)[]>([]); + const [autoplayDelay, setAutoplayDelay] = useState([]); + const { resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [ + Autoplay({ playOnInit: true, delay: autoplayDelay[selectedIndex] || 5000 }), + ]); + const [emblaThumbsRef, emblaThumbsApi] = useEmblaCarousel( + { + loop: true, + containScroll: 'keepSnaps', + dragFree: true, + }, + [Autoplay({ playOnInit: true, delay: autoplayDelay[selectedIndex] || 5000 })], + ); + + const onThumbClick = useCallback( + (index: number) => { + if (!emblaApi || !emblaThumbsApi) return; + emblaApi.scrollTo(index); + }, + [emblaApi, emblaThumbsApi], + ); + + const onSelect = useCallback(() => { + if (!emblaApi || !emblaThumbsApi) return; + setSelectedIndex(emblaApi.selectedScrollSnap()); + emblaThumbsApi.scrollTo(emblaApi.selectedScrollSnap()); + + resetProgress(); + const autoplay = emblaApi.plugins()?.autoplay; + + if (autoplay) { + autoplay.reset(); + } + }, [emblaApi, emblaThumbsApi, setSelectedIndex]); + + const resetProgress = useCallback(() => { + setProgress(0); + }, []); + + useEffect(() => { + const setVideoDurations = async () => { + const durations = await Promise.all( + videoRefs.current.map( + async (video) => + new Promise((resolve) => { + if (video) { + video.onloadedmetadata = () => resolve(video.duration * 1000); + } else { + resolve(5000); + } + }), + ), + ); + + setAutoplayDelay(durations); + }; + + void setVideoDurations(); + }, [slides, mounted, resolvedTheme]); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const video = entry.target as HTMLVideoElement; + video + .play() + .catch((error) => console.log('Error attempting to play the video:', error)); + } else { + const video = entry.target as HTMLVideoElement; + video.pause(); + } + }); + }, + { + threshold: 0.5, + }, + ); + + videoRefs.current.forEach((video) => { + if (video) { + observer.observe(video); + } + }); + + return () => { + observer.disconnect(); + }; + }, [slides, mounted, resolvedTheme]); + + useEffect(() => { + if (!emblaApi) return; + onSelect(); + + emblaApi.on('select', onSelect).on('reInit', onSelect); + }, [emblaApi, onSelect, mounted, resolvedTheme]); + + useEffect(() => { + const autoplay = emblaApi?.plugins()?.autoplay; + if (!autoplay) return; + + setIsPlaying(autoplay.isPlaying()); + emblaApi + .on('autoplay:play', () => setIsPlaying(true)) + .on('autoplay:stop', () => setIsPlaying(false)) + .on('reInit', () => setIsPlaying(autoplay.isPlaying())); + }, [emblaApi, mounted, resolvedTheme]); + + useEffect(() => { + if (autoplayDelay[selectedIndex] === undefined) return; + + const updateInterval = 50; + const increment = 100 / (autoplayDelay[selectedIndex] / updateInterval); + let progressValue = 0; + + const timer = setInterval(() => { + setProgress((prevProgress) => { + progressValue = prevProgress + increment; + if (progressValue >= 100) { + clearInterval(timer); + if (emblaApi) { + emblaApi.scrollNext(); + } + return 100; + } + return progressValue; + }); + }, updateInterval); + + return () => clearInterval(timer); + }, [selectedIndex, autoplayDelay, emblaApi, mounted, resolvedTheme]); + + useEffect(() => { + if (!emblaApi) return; + + const resetCarousel = () => { + emblaApi.reInit(); + emblaApi.scrollTo(0); + }; + + resetCarousel(); + }, [emblaApi, autoplayDelay, mounted, resolvedTheme]); + + // Ensure the component renders only after mounting to avoid theme issues + if (!mounted) return null; + return ( + <> + +
+
+ {slides.map((slide, index) => ( +
+ {slide.type === 'video' && ( + + )} +
+ ))} +
+
+ +
+ + {selectedIndex + 1}/{slides.length} + + +
+
+ +
+
+ {slides.map((slide, index) => ( + onThumbClick(index)} + selected={index === selectedIndex} + index={index} + label={slide.label} + /> + ))} +
+
+ + ); +}; diff --git a/apps/marketing/src/components/(marketing)/enterprise.tsx b/apps/marketing/src/components/(marketing)/enterprise.tsx new file mode 100644 index 000000000..a9ddd3606 --- /dev/null +++ b/apps/marketing/src/components/(marketing)/enterprise.tsx @@ -0,0 +1,36 @@ +'use client'; + +import Link from 'next/link'; + +import { usePlausible } from 'next-plausible'; + +import { Button } from '@documenso/ui/primitives/button'; + +export const Enterprise = () => { + const event = usePlausible(); + + return ( +
+

+ Enterprise Compliance, License or Technical Needs? +

+ +

+ Our Enterprise License is great large organizations looking to switch to Documenso for all + their signing needs. It's availible for our cloud offering as well as self-hosted setups and + offer a wide range of compliance and Adminstration Features. +

+ +
+ event('enterprise-contact')} + > + + +
+
+ ); +}; diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index e9a08049c..e259a0c71 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -35,6 +35,7 @@ const FOOTER_LINKS = [ { href: '/oss-friends', text: 'OSS Friends' }, { href: '/careers', text: 'Careers' }, { href: '/privacy', text: 'Privacy' }, + { href: '/changelog', text: 'Changelog' }, ]; export const Footer = ({ className, ...props }: FooterProps) => { diff --git a/apps/marketing/src/components/(marketing)/hero.tsx b/apps/marketing/src/components/(marketing)/hero.tsx index 5809bd695..8af38f1c8 100644 --- a/apps/marketing/src/components/(marketing)/hero.tsx +++ b/apps/marketing/src/components/(marketing)/hero.tsx @@ -14,7 +14,7 @@ import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-fl import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { Widget } from './widget'; +import { Carousel } from './carousel'; export type HeroProps = { className?: string; @@ -50,6 +50,21 @@ const HeroTitleVariants: Variants = { }, }; +const HeroCarouselVariants: Variants = { + initial: { + opacity: 0, + y: 60, + }, + animate: { + opacity: 1, + y: 0, + transition: { + delay: 0.5, + duration: 0.8, + }, + }, +}; + export const Hero = ({ className, ...props }: HeroProps) => { const event = usePlausible(); @@ -57,23 +72,6 @@ export const Hero = ({ className, ...props }: HeroProps) => { const heroMarketingCTA = getFlag('marketing_landing_hero_cta'); - const onSignUpClick = () => { - const el = document.getElementById('email'); - - if (el) { - const { top } = el.getBoundingClientRect(); - - window.scrollTo({ - top: top - 120, - behavior: 'smooth', - }); - - requestAnimationFrame(() => { - el.focus(); - }); - } - }; - return (
@@ -108,18 +106,18 @@ export const Hero = ({ className, ...props }: HeroProps) => { animate="animate" className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4" > - - + + + event('view-github')}>
diff --git a/apps/marketing/src/components/(marketing)/pricing-table.tsx b/apps/marketing/src/components/(marketing)/pricing-table.tsx index b34173ba1..892e031e6 100644 --- a/apps/marketing/src/components/(marketing)/pricing-table.tsx +++ b/apps/marketing/src/components/(marketing)/pricing-table.tsx @@ -58,7 +58,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => { > Yearly
- Save $60 + Save $60 or $120
{period === 'YEARLY' && ( { data-plan="free" className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg" > -

Free Plan

+

Free

$0

@@ -102,10 +102,10 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {

-

Early Adopters

+

Individual

{period === 'MONTHLY' && $30} @@ -114,12 +114,12 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {

- For fast-growing companies that aim to scale across multiple teams. + Everything you need for a great signing experience.

-

- - Limited Time Offer: Read More - -

-

Unlimited Teams

-

Unlimited Users

-

Unlimited Documents per month

-

Includes all upcoming features

-

Email, Discord and Slack assistance

+

Unlimited Documents per Month

+

API Accesss

+

Email and Discord Support

+

Premium Profile Name

-

Enterprise

-

Pricing on request

+

Teams

+
+ + {period === 'MONTHLY' && $50} + {period === 'YEARLY' && $480} + +

- For large organizations that need extra flexibility and control. + For companies looking to scale across multiple teams.

- event('enterprise-contact')} - > - - +
-

Everything in Early Adopters, plus:

-

Custom Subdomain

-

Compliance Check

-

Guaranteed Uptime

-

Reporting & Analysis

-

24/7 Support

+

Unlimited Documents per Month

+

API Accesss

+

Email and Discord Support

+

Team Inbox

+

5 Users Included

+

+ Add More Users for {period === 'MONTHLY' ? '$10/ mo.' : '$96/ yr.'} +

diff --git a/apps/marketing/src/components/(marketing)/slide.tsx b/apps/marketing/src/components/(marketing)/slide.tsx new file mode 100644 index 000000000..083641527 --- /dev/null +++ b/apps/marketing/src/components/(marketing)/slide.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { cn } from '@documenso/ui/lib/utils'; + +type SlideProps = { + selected: boolean; + index: number; + onClick: () => void; + label: string; +}; + +export const Slide: React.FC = (props) => { + const { selected, label, onClick } = props; + + return ( + + ); +}; diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx deleted file mode 100644 index c4611746a..000000000 --- a/apps/marketing/src/components/(marketing)/widget.tsx +++ /dev/null @@ -1,421 +0,0 @@ -'use client'; - -import type { HTMLAttributes, KeyboardEvent } from 'react'; -import { useMemo, useState } from 'react'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { AnimatePresence, motion } from 'framer-motion'; -import { Loader } from 'lucide-react'; -import { usePlausible } from 'next-plausible'; -import { env } from 'next-runtime-env'; -import { Controller, useForm } from 'react-hook-form'; -import { z } from 'zod'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { Input } from '@documenso/ui/primitives/input'; -import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { claimPlan } from '~/api/claim-plan/fetcher'; - -import { STEP } from '../constants'; -import { FormErrorMessage } from '../form/form-error-message'; - -const ZWidgetFormSchema = z - .object({ - email: z.string().email({ message: 'Please enter a valid email address.' }), - name: z.string().trim().min(3, { message: 'Please enter a valid name.' }), - }) - .and( - z.union([ - z.object({ - signatureDataUrl: z.string().min(1), - signatureText: z.null().or(z.string().max(0)), - }), - z.object({ - signatureDataUrl: z.null().or(z.string().max(0)), - signatureText: z.string().trim().min(1), - }), - ]), - ); - -export type TWidgetFormSchema = z.infer; - -type StepKeys = keyof typeof STEP; -type StepValues = (typeof STEP)[StepKeys]; - -export type WidgetProps = HTMLAttributes; - -export const Widget = ({ className, children, ...props }: WidgetProps) => { - const { toast } = useToast(); - const event = usePlausible(); - - const [step, setStep] = useState(STEP.EMAIL); - const [showSigningDialog, setShowSigningDialog] = useState(false); - const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState(null); - - const { - control, - register, - handleSubmit, - setValue, - trigger, - watch, - formState: { errors, isSubmitting, isValid }, - } = useForm({ - mode: 'onChange', - defaultValues: { - email: '', - name: '', - signatureDataUrl: null, - signatureText: '', - }, - resolver: zodResolver(ZWidgetFormSchema), - }); - - const signatureDataUrl = watch('signatureDataUrl'); - const signatureText = watch('signatureText'); - - const stepsRemaining = useMemo(() => { - if (step === STEP.NAME) { - return 2; - } - - if (step === STEP.EMAIL) { - return 3; - } - - return 1; - }, [step]); - - const onNextStepClick = () => { - if (step === STEP.EMAIL) { - setStep(STEP.NAME); - - setTimeout(() => { - document.querySelector('#name')?.focus(); - }, 0); - } - - if (step === STEP.NAME) { - setStep(STEP.SIGN); - - setTimeout(() => { - document.querySelector('#signatureText')?.focus(); - }, 0); - } - }; - - const onEnterPress = (callback: () => void) => { - return (e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - - callback(); - } - }; - }; - - const onSignatureConfirmClick = () => { - setValue('signatureDataUrl', draftSignatureDataUrl); - setValue('signatureText', ''); - - void trigger('signatureDataUrl'); - setShowSigningDialog(false); - }; - - const onFormSubmit = async ({ - email, - name, - signatureDataUrl, - signatureText, - }: TWidgetFormSchema) => { - try { - const delay = new Promise((resolve) => { - setTimeout(resolve, 1000); - }); - - const planId = env('NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID'); - - if (!planId) { - throw new Error('No plan ID found.'); - } - - const claimPlanInput = signatureDataUrl - ? { - name, - email, - planId, - signatureDataUrl: signatureDataUrl, - signatureText: null, - } - : { - name, - email, - planId, - signatureDataUrl: null, - signatureText: signatureText ?? '', - }; - - const [result] = await Promise.all([claimPlan(claimPlanInput), delay]); - - event('claim-plan-widget'); - - window.location.href = result; - } catch (error) { - event('claim-plan-failed'); - - toast({ - title: 'Something went wrong', - description: error instanceof Error ? error.message : 'Please try again later.', - variant: 'destructive', - }); - } - }; - - return ( - <> - -
-
- {children} -
- -
-

Sign up to Early Adopter Plan

-

- with Timur Ercan & Lucas Smith from Documenso -

- -
- - - - - - ( -
- - field.value !== '' && - !errors.email?.message && - onEnterPress(onNextStepClick)(e) - } - {...field} - /> - -
- -
-
- )} - /> - - -
- - {(step === STEP.NAME || step === STEP.SIGN) && ( - - - - ( -
- - field.value !== '' && - !errors.name?.message && - onEnterPress(onNextStepClick)(e) - } - {...field} - /> - -
- -
-
- )} - /> - - -
- )} -
- -
- -
-

- {isValid ? 'Ready for Signing' : `${stepsRemaining} step(s) until signed`} -

- -

Minimise contract

-
- -
-
-
- - - setShowSigningDialog(true)} - > -
- {!signatureText && signatureDataUrl && ( - user signature - )} - - {signatureText && ( -

- {signatureText} -

- )} -
- -
e.stopPropagation()} - > - { - if (e.target.value !== '') { - setValue('signatureDataUrl', null); - } - }, - })} - /> - - -
-
-
- -
- - - - - - Add your signature - - - - By signing you signal your support of Documenso's mission in a

- non-legally binding, but heartfelt way.

-

You also unlock the option to purchase the early supporter plan including - everything we build this year for fixed price. -
- - - - - - - - -
-
- - ); -}; diff --git a/apps/web/package.json b/apps/web/package.json index 71eea5555..05a00c47d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -48,8 +48,9 @@ "react-hotkeys-hook": "^4.4.1", "react-icons": "^4.11.0", "react-rnd": "^10.4.1", + "recharts": "^2.7.2", "remeda": "^1.27.1", - "sharp": "^0.33.1", + "sharp": "0.32.6", "ts-pattern": "^5.0.5", "ua-parser-js": "^1.0.37", "uqr": "^0.1.2", @@ -75,4 +76,4 @@ "next": "$next" } } -} +} \ No newline at end of file diff --git a/apps/web/public/static/early-supporter-badge.svg b/apps/web/public/static/early-supporter-badge.svg new file mode 100644 index 000000000..11efe2193 --- /dev/null +++ b/apps/web/public/static/early-supporter-badge.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/static/premium-user-badge.svg b/apps/web/public/static/premium-user-badge.svg new file mode 100644 index 000000000..0a448c4e7 --- /dev/null +++ b/apps/web/public/static/premium-user-badge.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/src/app/(dashboard)/admin/stats/page.tsx b/apps/web/src/app/(dashboard)/admin/stats/page.tsx index 43fe4be01..bcce0b608 100644 --- a/apps/web/src/app/(dashboard)/admin/stats/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/stats/page.tsx @@ -14,18 +14,34 @@ import { import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats'; import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats'; import { + getUserWithAtLeastOneDocumentPerMonth, + getUserWithAtLeastOneDocumentSignedPerMonth, + getUserWithSignedDocumentMonthlyGrowth, getUsersCount, getUsersWithSubscriptionsCount, } from '@documenso/lib/server-only/admin/get-users-stats'; import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card'; +import { UserWithDocumentChart } from './user-with-document'; + export default async function AdminStatsPage() { - const [usersCount, usersWithSubscriptionsCount, docStats, recipientStats] = await Promise.all([ + const [ + usersCount, + usersWithSubscriptionsCount, + docStats, + recipientStats, + userWithAtLeastOneDocumentPerMonth, + userWithAtLeastOneDocumentSignedPerMonth, + MONTHLY_USERS_SIGNED, + ] = await Promise.all([ getUsersCount(), getUsersWithSubscriptionsCount(), getDocumentStats(), getRecipientsStats(), + getUserWithAtLeastOneDocumentPerMonth(), + getUserWithAtLeastOneDocumentSignedPerMonth(), + getUserWithSignedDocumentMonthlyGrowth(), ]); return ( @@ -43,12 +59,11 @@ export default async function AdminStatsPage() {
-
+

Document metrics

-
- +
@@ -58,7 +73,7 @@ export default async function AdminStatsPage() {

Recipients metrics

-
+
+ +
+

Charts

+
+ + +
+
); } diff --git a/apps/web/src/app/(dashboard)/admin/stats/user-with-document.tsx b/apps/web/src/app/(dashboard)/admin/stats/user-with-document.tsx new file mode 100644 index 000000000..cf9f11e23 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/stats/user-with-document.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { DateTime } from 'luxon'; +import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import type { TooltipProps } from 'recharts'; +import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'; + +import type { GetUserWithDocumentMonthlyGrowth } from '@documenso/lib/server-only/admin/get-users-stats'; + +export type UserWithDocumentChartProps = { + className?: string; + title: string; + data: GetUserWithDocumentMonthlyGrowth; + completed?: boolean; + tooltip?: string; +}; + +const CustomTooltip = ({ + active, + payload, + label, + tooltip, +}: TooltipProps & { tooltip?: string }) => { + if (active && payload && payload.length) { + return ( +
+

{label}

+

+ {`${tooltip} : `} + {payload[0].value} +

+
+ ); + } + + return null; +}; + +export const UserWithDocumentChart = ({ + className, + data, + title, + completed = false, + tooltip, +}: UserWithDocumentChartProps) => { + const formattedData = (data: GetUserWithDocumentMonthlyGrowth, completed: boolean) => { + return [...data].reverse().map(({ month, count, signed_count }) => { + const formattedMonth = DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL'); + if (completed) { + return { + month: formattedMonth, + count: Number(signed_count), + }; + } else { + return { + month: formattedMonth, + count: Number(count), + }; + } + }); + }; + + return ( +
+
+
+

{title}

+
+ + + + + + + } + labelStyle={{ + color: 'hsl(var(--primary-foreground))', + }} + cursor={{ fill: 'hsl(var(--primary) / 10%)' }} + /> + + + + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx index 1a5d2f554..78a192117 100644 --- a/apps/web/src/app/(dashboard)/admin/users/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -19,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag const [{ users, totalPages }, individualPrices] = await Promise.all([ search(searchString, page, perPage), - getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY).catch(() => []), + getPricesByPlan([STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY]).catch(() => []), ]); const individualPriceIds = individualPrices.map((price) => price.id); diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index ba9b806e5..643534a5b 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -8,7 +8,7 @@ import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; -import { getCompletedFieldsForDocument } from '@documenso/lib/server-only/field/get-completed-fields-for-document'; +import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; @@ -86,14 +86,15 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) documentMeta.password = securePassword; } - const [recipients, completedFields] = await Promise.all([ + const [recipients, fields] = await Promise.all([ getRecipientsForDocument({ documentId, teamId: team?.id, userId: user.id, }), - getCompletedFieldsForDocument({ + getFieldsForDocument({ documentId, + userId: user.id, }), ]); @@ -163,10 +164,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) {document.status === DocumentStatus.PENDING && ( - + )}
diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index 7865e2b5c..e686256e0 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -39,7 +39,7 @@ export default async function BillingSettingsPage() { const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([ getSubscriptionsByUserId({ userId: user.id }), - getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }), + getPricesByInterval({ plan: STRIPE_PLAN_TYPE.REGULAR }), getPrimaryAccountPlanPrices(), ]); diff --git a/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx b/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx deleted file mode 100644 index c894113b6..000000000 --- a/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -import type { User } from '@documenso/prisma/client'; -import { cn } from '@documenso/ui/lib/utils'; -import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; -import { Button } from '@documenso/ui/primitives/button'; - -import { ClaimPublicProfileDialogForm } from '~/components/forms/public-profile-claim-dialog'; - -export type ClaimProfileAlertDialogProps = { - className?: string; - user: User; -}; - -export const ClaimProfileAlertDialog = ({ className, user }: ClaimProfileAlertDialogProps) => { - const [open, setOpen] = useState(false); - - return ( - <> - -
- {user.url ? 'Update your profile' : 'Claim your profile'} - - {user.url - ? 'Profiles are coming soon! Update your profile username to reserve your corner of the signing revolution.' - : 'Profiles are coming soon! Claim your profile username now to reserve your corner of the signing revolution.'} - -
- -
- -
-
- - - - ); -}; diff --git a/apps/web/src/app/(dashboard)/settings/profile/page.tsx b/apps/web/src/app/(dashboard)/settings/profile/page.tsx index 669c149b5..07b536a35 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx @@ -3,9 +3,9 @@ import type { Metadata } from 'next'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { AvatarImageForm } from '~/components/forms/avatar-image'; import { ProfileForm } from '~/components/forms/profile'; -import { ClaimProfileAlertDialog } from './claim-profile-alert-dialog'; import { DeleteAccountDialog } from './delete-account-dialog'; export const metadata: Metadata = { @@ -19,10 +19,9 @@ export default async function ProfileSettingsPage() {
+ - -
diff --git a/apps/web/src/app/(dashboard)/settings/public-profile/page.tsx b/apps/web/src/app/(dashboard)/settings/public-profile/page.tsx new file mode 100644 index 000000000..f622b5636 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/public-profile/page.tsx @@ -0,0 +1,14 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile'; + +import { PublicProfilePageView } from './public-profile-page-view'; + +export default async function Page() { + const { user } = await getRequiredServerComponentSession(); + + const { profile } = await getUserPublicProfile({ + userId: user.id, + }); + + return ; +} diff --git a/apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx b/apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx new file mode 100644 index 000000000..90759f68e --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx @@ -0,0 +1,207 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; + +import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates'; +import type { + Team, + TeamProfile, + TemplateDirectLink, + User, + UserProfile, +} from '@documenso/prisma/client'; +import { TemplateType } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form'; +import { PublicProfileForm } from '~/components/forms/public-profile-form'; +import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog'; + +import { PublicTemplatesDataTable } from './public-templates-data-table'; + +export type PublicProfilePageViewOptions = { + user: User; + team?: Team; + profile: UserProfile | TeamProfile; +}; + +type DirectTemplate = FindTemplateRow & { + directLink: Pick; +}; + +const userProfileText = { + settingsTitle: 'Public Profile', + settingsSubtitle: 'You can choose to enable or disable your profile for public view.', + templatesTitle: 'My templates', + templatesSubtitle: + 'Show templates in your public profile for your audience to sign and get started quickly', +}; + +const teamProfileText = { + settingsTitle: 'Team Public Profile', + settingsSubtitle: 'You can choose to enable or disable your team profile for public view.', + templatesTitle: 'Team templates', + templatesSubtitle: + 'Show templates in your team public profile for your audience to sign and get started quickly', +}; + +export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePageViewOptions) => { + const { toast } = useToast(); + + const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled); + const [isTooltipOpen, setIsTooltipOpen] = useState(false); + + const { data } = trpc.template.findTemplates.useQuery({ + perPage: 100, + teamId: team?.id, + }); + + const { mutateAsync: updateUserProfile, isLoading: isUpdatingUserProfile } = + trpc.profile.updatePublicProfile.useMutation(); + + const { mutateAsync: updateTeamProfile, isLoading: isUpdatingTeamProfile } = + trpc.team.updateTeamPublicProfile.useMutation(); + + const isUpdating = isUpdatingUserProfile || isUpdatingTeamProfile; + const profileText = team ? teamProfileText : userProfileText; + + const enabledPrivateDirectTemplates = useMemo( + () => + (data?.templates ?? []).filter( + (template): template is DirectTemplate => + template.directLink?.enabled === true && template.type !== TemplateType.PUBLIC, + ), + [data], + ); + + const onProfileUpdate = async (data: TPublicProfileFormSchema) => { + if (team) { + await updateTeamProfile({ + teamId: team.id, + ...data, + }); + } else { + await updateUserProfile(data); + } + + if (data.enabled === undefined && !isPublicProfileVisible) { + setIsTooltipOpen(true); + } + }; + + const togglePublicProfileVisibility = async (isVisible: boolean) => { + setIsTooltipOpen(false); + + if (isUpdating) { + return; + } + + if (isVisible && !user.url) { + toast({ + title: 'You must set a profile URL before enabling your public profile.', + variant: 'destructive', + }); + + return; + } + + setIsPublicProfileVisible(isVisible); + + try { + await onProfileUpdate({ + enabled: isVisible, + }); + } catch { + toast({ + title: 'Something went wrong', + description: 'We were unable to set your public profile to public. Please try again.', + variant: 'destructive', + }); + + setIsPublicProfileVisible(!isVisible); + } + }; + + useEffect(() => { + setIsPublicProfileVisible(profile.enabled); + }, [profile.enabled]); + + return ( +
+ + + +
*:first-child]:text-muted-foreground': !isPublicProfileVisible, + '[&>*:last-child]:text-muted-foreground': isPublicProfileVisible, + }, + )} + > + Hide + + Show +
+
+ + + {isPublicProfileVisible ? ( + <> +

+ Profile is currently visible. +

+ +

Toggle the switch to hide your profile from the public.

+ + ) : ( + <> +

+ Profile is currently hidden. +

+ +

Toggle the switch to show your profile to the public.

+ + )} +
+
+
+ + + +
+ + Link template} + /> + + +
+ +
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/public-profile/public-templates-data-table.tsx b/apps/web/src/app/(dashboard)/settings/public-profile/public-templates-data-table.tsx new file mode 100644 index 000000000..692e01ac6 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/public-profile/public-templates-data-table.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react'; + +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates'; +import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; +import type { TemplateDirectLink } from '@documenso/prisma/client'; +import { TemplateType } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog'; +import { useOptionalCurrentTeam } from '~/providers/team'; + +type DirectTemplate = FindTemplateRow & { + directLink: Pick; +}; + +export const PublicTemplatesDataTable = () => { + const team = useOptionalCurrentTeam(); + + const { toast } = useToast(); + + const [, copy] = useCopyToClipboard(); + + const [publicTemplateDialogPayload, setPublicTemplateDialogPayload] = useState<{ + step: 'MANAGE' | 'CONFIRM_DISABLE'; + templateId: number; + } | null>(null); + + const { data, isInitialLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery( + { + teamId: team?.id, + }, + { + keepPreviousData: true, + }, + ); + + const { directTemplates, publicDirectTemplates, privateDirectTemplates } = useMemo(() => { + const directTemplates = (data?.templates ?? []).filter( + (template): template is DirectTemplate => template.directLink?.enabled === true, + ); + + const publicDirectTemplates = directTemplates.filter( + (template) => template.directLink?.enabled === true && template.type === TemplateType.PUBLIC, + ); + + const privateDirectTemplates = directTemplates.filter( + (template) => template.directLink?.enabled === true && template.type === TemplateType.PRIVATE, + ); + + return { + directTemplates, + publicDirectTemplates, + privateDirectTemplates, + }; + }, [data]); + + const onCopyClick = async (token: string) => + copy(formatDirectTemplatePath(token)).then(() => { + toast({ + title: 'Copied to clipboard', + description: 'The direct link has been copied to your clipboard', + }); + }); + + return ( +
+
+ {/* Loading and error handling states. */} + {publicDirectTemplates.length === 0 && ( + <> + {isInitialLoading && + Array(3) + .fill(0) + .map((_, index) => ( +
+
+ + +
+ + +
+
+ + +
+ ))} + + {isLoadingError && ( +
+ Unable to load your public profile templates at this time + +
+ )} + + {!isInitialLoading && ( +
+ No public profile templates found + + Click here to get started + + } + /> +
+ )} + + )} + + {/* Public templates list. */} + {publicDirectTemplates.map((template) => ( +
+
+ + +
+

{template.publicTitle}

+

{template.publicDescription}

+
+
+ + + + + + + + Action + + void onCopyClick(template.directLink.token)}> + + Copy sharable link + + + { + setPublicTemplateDialogPayload({ + step: 'MANAGE', + templateId: template.id, + }); + }} + > + + Update + + + + setPublicTemplateDialogPayload({ + step: 'CONFIRM_DISABLE', + templateId: template.id, + }) + } + > + + Remove + + + +
+ ))} +
+ + { + if (!value) { + setPublicTemplateDialogPayload(null); + } + }} + /> +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx index f728174a6..315c2022b 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx @@ -97,6 +97,7 @@ export const DataTableActionDropdown = ({ diff --git a/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx index b31ad2048..9ded79fa5 100644 --- a/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx @@ -14,11 +14,17 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; type DeleteTemplateDialogProps = { id: number; + teamId?: number; open: boolean; onOpenChange: (_open: boolean) => void; }; -export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => { +export const DeleteTemplateDialog = ({ + id, + teamId, + open, + onOpenChange, +}: DeleteTemplateDialogProps) => { const router = useRouter(); const { toast } = useToast(); @@ -67,7 +73,12 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD Cancel - diff --git a/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx b/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx index 6874fef90..c4fe8d714 100644 --- a/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx @@ -383,7 +383,7 @@ export const TemplateDirectLinkDialog = ({
- + +
+
+
+ ); +} diff --git a/apps/web/src/app/(profile)/p/[url]/page.tsx b/apps/web/src/app/(profile)/p/[url]/page.tsx new file mode 100644 index 000000000..f8d197fff --- /dev/null +++ b/apps/web/src/app/(profile)/p/[url]/page.tsx @@ -0,0 +1,194 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import { notFound, redirect } from 'next/navigation'; + +import { FileIcon } from 'lucide-react'; +import { DateTime } from 'luxon'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getPublicProfileByUrl } from '@documenso/lib/server-only/profile/get-public-profile-by-url'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; +import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@documenso/ui/primitives/table'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; + +export type PublicProfilePageProps = { + params: { + url: string; + }; +}; + +const BADGE_DATA = { + Premium: { + imageSrc: '/static/premium-user-badge.svg', + name: 'Premium', + }, + EarlySupporter: { + imageSrc: '/static/early-supporter-badge.svg', + name: 'Early supporter', + }, +}; + +export default async function PublicProfilePage({ params }: PublicProfilePageProps) { + const { url: profileUrl } = params; + + if (!profileUrl) { + redirect('/'); + } + + const publicProfile = await getPublicProfileByUrl({ + profileUrl, + }).catch(() => null); + + if (!publicProfile || !publicProfile.profile.enabled) { + notFound(); + } + + const { user } = await getServerComponentSession(); + + const { profile, templates } = publicProfile; + + return ( +
+
+ + {publicProfile.avatarImageId && ( + + )} + + + {extractInitials(publicProfile.name)} + + + +
+

{publicProfile.name}

+ + {publicProfile.badge && ( + + + Profile badge + + + + Profile badge + +
+

+ {BADGE_DATA[publicProfile.badge.type].name} +

+

+ Since {DateTime.fromJSDate(publicProfile.badge.since).toFormat('LLL ‘yy')} +

+
+
+
+ )} +
+ +
+ {(profile.bio ?? '').split('\n').map((line, index) => ( +

+ {line} +

+ ))} +
+
+ + {templates.length === 0 && ( +
+

+ It looks like {publicProfile.name} hasn't added any documents to their profile yet.{' '} + {!user?.id && ( + + While waiting for them to do so you can create your own Documenso account and get + started with document signing right away. + + )} + {'userId' in profile && user?.id === profile.userId && ( + + Go to your{' '} + + public profile settings + {' '} + to add documents. + + )} +

+
+ )} + + {templates.length > 0 && ( +
+ + + + + Documents + + + + + {templates.map((template) => ( + + +
+ + +
+
+

+ {template.publicTitle} +

+

+ {template.publicDescription} +

+
+ + +
+
+
+
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(profile)/profile-header.tsx b/apps/web/src/app/(profile)/profile-header.tsx new file mode 100644 index 000000000..b29569dce --- /dev/null +++ b/apps/web/src/app/(profile)/profile-header.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import Image from 'next/image'; +import Link from 'next/link'; + +import { PlusIcon } from 'lucide-react'; + +import LogoIcon from '@documenso/assets/logo_icon.png'; +import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; +import type { User } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header'; +import { Logo } from '~/components/branding/logo'; + +type ProfileHeaderProps = { + user?: User | null; + teams?: GetTeamsResponse; +}; + +export const ProfileHeader = ({ user, teams = [] }: ProfileHeaderProps) => { + const [scrollY, setScrollY] = useState(0); + + useEffect(() => { + const onScroll = () => { + setScrollY(window.scrollY); + }; + + window.addEventListener('scroll', onScroll); + + return () => window.removeEventListener('scroll', onScroll); + }, []); + + if (user) { + return ; + } + + return ( +
5 && 'border-b-border', + )} + > +
+ + + + Documenso Logo + + +
+

+ Want your own public profile? + + Like to have your own public profile with agreements? + +

+ + +
+
+
+ ); +}; diff --git a/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx b/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx index 8bb3756f4..2ef832dfc 100644 --- a/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx +++ b/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import type { Field } from '@documenso/prisma/client'; import { type Recipient } from '@documenso/prisma/client'; import type { TemplateWithDetails } from '@documenso/prisma/types/template'; @@ -47,6 +48,8 @@ export const DirectTemplatePageView = ({ const [step, setStep] = useState('configure'); const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); + const recipientRoleDescription = RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role]; + const directTemplateFlow: Record = { configure: { title: 'General', @@ -54,8 +57,8 @@ export const DirectTemplatePageView = ({ stepIndex: 1, }, sign: { - title: 'Sign document', - description: 'Sign the document to complete the process.', + title: `${recipientRoleDescription.actionVerb} document`, + description: `${recipientRoleDescription.actionVerb} the document to complete the process.`, stepIndex: 2, }, }; diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 3ae09f662..ef4b07c8a 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -10,6 +10,7 @@ import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; +import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; @@ -70,8 +71,14 @@ export default async function SigningPage({ params: { token } }: SigningPageProp userId: user?.id, }); + let recipientHasAccount: boolean | null = null; + if (!isDocumentAccessValid) { - return ; + recipientHasAccount = await getUserByEmail({ email: recipient?.email }) + .then((user) => !!user) + .catch(() => false); + + return ; } await viewedDocument({ diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-auth-page.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-auth-page.tsx index fb19384cd..2d77679df 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-auth-page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-auth-page.tsx @@ -11,9 +11,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type SigningAuthPageViewProps = { email: string; + emailHasAccount?: boolean; }; -export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => { +export const SigningAuthPageView = ({ email, emailHasAccount }: SigningAuthPageViewProps) => { const { toast } = useToast(); const [isSigningOut, setIsSigningOut] = useState(false); @@ -30,7 +31,9 @@ export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => { }); await signOut({ - callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`, + callbackUrl: emailHasAccount + ? `/signin?email=${encodeURIComponent(encryptedEmail)}` + : `/signup?email=${encodeURIComponent(encryptedEmail)}`, }); } catch { toast({ @@ -59,7 +62,7 @@ export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => { onClick={async () => handleChangeAccount(email)} loading={isSigningOut} > - Login + {emailHasAccount ? 'Login' : 'Sign up'}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx index a86797191..88782b1e7 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx @@ -13,6 +13,7 @@ import { AddTeamEmailDialog } from '~/components/(teams)/dialogs/add-team-email- import { DeleteTeamDialog } from '~/components/(teams)/dialogs/delete-team-dialog'; import { TransferTeamDialog } from '~/components/(teams)/dialogs/transfer-team-dialog'; import { UpdateTeamForm } from '~/components/(teams)/forms/update-team-form'; +import { AvatarImageForm } from '~/components/forms/avatar-image'; import { TeamEmailDropdown } from './team-email-dropdown'; import { TeamTransferStatus } from './team-transfer-status'; @@ -35,7 +36,7 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro return (
- + + +
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/public-profile/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/public-profile/page.tsx new file mode 100644 index 000000000..494faaa9b --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/public-profile/page.tsx @@ -0,0 +1,28 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; +import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile'; + +import { PublicProfilePageView } from '~/app/(dashboard)/settings/public-profile/public-profile-page-view'; + +export type TeamsSettingsPublicProfilePageProps = { + params: { + teamUrl: string; + }; +}; + +export default async function TeamsSettingsPublicProfilePage({ + params, +}: TeamsSettingsPublicProfilePageProps) { + const { teamUrl } = params; + + const { user } = await getRequiredServerComponentSession(); + + const team = await getTeamByUrl({ userId: user.id, teamUrl }); + + const { profile } = await getTeamPublicProfile({ + userId: user.id, + teamId: team.id, + }); + + return ; +} diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx index 4895a61b3..760b9cad2 100644 --- a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx +++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx @@ -7,6 +7,7 @@ import { motion } from 'framer-motion'; import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react'; import { signOut } from 'next-auth/react'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams'; import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; @@ -99,6 +100,9 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2" > +
{ const { getFlag } = useFeatureFlags(); const isBillingEnabled = getFlag('app_billing'); + const isPublicProfileEnabled = getFlag('app_public_profile'); return (
@@ -35,6 +36,21 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + {isPublicProfileEnabled && ( + + + + )} + + {isPublicProfileEnabled && ( + + + + )} +
diff --git a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx index 6964b2cee..7c08d174f 100644 --- a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx @@ -5,8 +5,9 @@ import type { HTMLAttributes } from 'react'; import Link from 'next/link'; import { useParams, usePathname } from 'next/navigation'; -import { Braces, CreditCard, Settings, Users, Webhook } from 'lucide-react'; +import { Braces, CreditCard, Globe2Icon, Settings, Users, Webhook } from 'lucide-react'; +import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -17,9 +18,14 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { const pathname = usePathname(); const params = useParams(); + const { getFlag } = useFeatureFlags(); + + const isPublicProfileEnabled = getFlag('app_public_profile'); + const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : ''; const settingsPath = `/t/${teamUrl}/settings`; + const publicProfilePath = `/t/${teamUrl}/settings/public-profile`; const membersPath = `/t/${teamUrl}/settings/members`; const tokensPath = `/t/${teamUrl}/settings/tokens`; const webhooksPath = `/t/${teamUrl}/settings/webhooks`; @@ -37,6 +43,21 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + {isPublicProfileEnabled && ( + + + + )} + + {isPublicProfileEnabled && ( + + + + )} + + +
- {match(field) - .with({ type: FieldType.SIGNATURE }, (field) => - field.Signature?.signatureImageAsBase64 ? ( - Signature - ) : ( -

- {field.Signature?.typedSignature} -

- ), - ) - .with( - { type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) }, - () => field.customText, - ) - .with({ type: FieldType.DATE }, () => - convertToLocalSystemFormat( - field.customText, - documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, - documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, - ), - ) - .with({ type: FieldType.FREE_SIGNATURE }, () => null) - .exhaustive()} + {field.Recipient.signingStatus === SigningStatus.SIGNED && + match(field) + .with({ type: FieldType.SIGNATURE }, (field) => + field.Signature?.signatureImageAsBase64 ? ( + Signature + ) : ( +

+ {field.Signature?.typedSignature} +

+ ), + ) + .with( + { type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) }, + () => field.customText, + ) + .with({ type: FieldType.DATE }, () => + convertToLocalSystemFormat( + field.customText, + documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, + documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, + ), + ) + .with({ type: FieldType.FREE_SIGNATURE }, () => null) + .exhaustive()} + + {field.Recipient.signingStatus === SigningStatus.NOT_SIGNED && ( +

+ {FRIENDLY_FIELD_TYPE[field.type]} +

+ )}
), diff --git a/apps/web/src/components/forms/avatar-image.tsx b/apps/web/src/components/forms/avatar-image.tsx new file mode 100644 index 000000000..20917d204 --- /dev/null +++ b/apps/web/src/components/forms/avatar-image.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { useMemo } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { ErrorCode, useDropzone } from 'react-dropzone'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { base64 } from '@documenso/lib/universal/base64'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import type { Team, User } from '@documenso/prisma/client'; +import { TRPCClientError } from '@documenso/trpc/client'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export const ZAvatarImageFormSchema = z.object({ + bytes: z.string().nullish(), +}); + +export type TAvatarImageFormSchema = z.infer; + +export type AvatarImageFormProps = { + className?: string; + user: User; + team?: Team; +}; + +export const AvatarImageForm = ({ className, user, team }: AvatarImageFormProps) => { + const { toast } = useToast(); + const router = useRouter(); + + const { mutateAsync: setProfileImage } = trpc.profile.setProfileImage.useMutation(); + + const initials = extractInitials(team?.name || user.name || ''); + + const hasAvatarImage = useMemo(() => { + if (team) { + return team.avatarImageId !== null; + } + + return user.avatarImageId !== null; + }, [team, user.avatarImageId]); + + const avatarImageId = team ? team.avatarImageId : user.avatarImageId; + + const form = useForm({ + values: { + bytes: null, + }, + resolver: zodResolver(ZAvatarImageFormSchema), + }); + + const { getRootProps, getInputProps } = useDropzone({ + maxSize: 1024 * 1024, + accept: { + 'image/*': ['.png', '.jpg', '.jpeg'], + }, + multiple: false, + onDropAccepted: ([file]) => { + void file.arrayBuffer().then((buffer) => { + const contents = base64.encode(new Uint8Array(buffer)); + + form.setValue('bytes', contents); + void form.handleSubmit(onFormSubmit)(); + }); + }, + onDropRejected: ([file]) => { + form.setError('bytes', { + type: 'onChange', + message: match(file.errors[0].code) + .with(ErrorCode.FileTooLarge, () => 'Uploaded file is too large') + .with(ErrorCode.FileTooSmall, () => 'Uploaded file is too small') + .with(ErrorCode.FileInvalidType, () => 'Uploaded file not an allowed file type') + .otherwise(() => 'An unknown error occurred'), + }); + }, + }); + + const onFormSubmit = async (data: TAvatarImageFormSchema) => { + try { + await setProfileImage({ + bytes: data.bytes, + teamId: team?.id, + }); + + toast({ + title: 'Avatar Updated', + description: 'Your avatar has been updated successfully.', + duration: 5000, + }); + + router.refresh(); + } catch (err) { + if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { + toast({ + title: 'An error occurred', + description: err.message, + variant: 'destructive', + }); + } else { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to update the avatar. Please try again later.', + }); + } + } + }; + + return ( +
+ +
+ ( + + Avatar + + +
+
+ + {avatarImageId && ( + + )} + + {initials} + + + + {hasAvatarImage && ( + + )} +
+ + +
+
+ + +
+ )} + /> +
+
+ + ); +}; diff --git a/apps/web/src/components/forms/public-profile-form.tsx b/apps/web/src/components/forms/public-profile-form.tsx new file mode 100644 index 000000000..3607d7fe1 --- /dev/null +++ b/apps/web/src/components/forms/public-profile-form.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { motion } from 'framer-motion'; +import { AnimatePresence } from 'framer-motion'; +import { CheckSquareIcon, CopyIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { formatUserProfilePath } from '@documenso/lib/utils/public-profiles'; +import type { TeamProfile, UserProfile } from '@documenso/prisma/client'; +import { + MAX_PROFILE_BIO_LENGTH, + ZUpdatePublicProfileMutationSchema, +} from '@documenso/trpc/server/profile-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export const ZPublicProfileFormSchema = ZUpdatePublicProfileMutationSchema.pick({ + bio: true, + enabled: true, + url: true, +}); + +export type TPublicProfileFormSchema = z.infer; + +export type PublicProfileFormProps = { + className?: string; + profileUrl?: string | null; + teamUrl?: string; + onProfileUpdate: (data: TPublicProfileFormSchema) => Promise; + profile: UserProfile | TeamProfile; +}; +export const PublicProfileForm = ({ + className, + profileUrl, + profile, + teamUrl, + onProfileUpdate, +}: PublicProfileFormProps) => { + const { toast } = useToast(); + + const [, copy] = useCopyToClipboard(); + + const [copiedTimeout, setCopiedTimeout] = useState(null); + + const form = useForm({ + values: { + url: profileUrl ?? '', + bio: profile?.bio ?? '', + }, + resolver: zodResolver(ZPublicProfileFormSchema), + }); + + const isSubmitting = form.formState.isSubmitting; + + const onFormSubmit = async (data: TPublicProfileFormSchema) => { + try { + await onProfileUpdate(data); + + toast({ + title: 'Success', + description: 'Your public profile has been updated.', + duration: 5000, + }); + + form.reset({ + url: data.url, + bio: data.bio, + }); + } catch (err) { + const error = AppError.parseError(err); + + switch (error.code) { + case AppErrorCode.PREMIUM_PROFILE_URL: + case AppErrorCode.PROFILE_URL_TAKEN: + form.setError('url', { + type: 'manual', + message: error.message, + }); + + break; + + default: + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to update your public profile. Please try again later.', + }); + } + } + }; + + const onCopy = async () => { + await copy(formatUserProfilePath(form.getValues('url') ?? '')).then(() => { + toast({ + title: 'Copied to clipboard', + description: 'The profile link has been copied to your clipboard', + }); + }); + + if (copiedTimeout) { + clearTimeout(copiedTimeout); + } + + setCopiedTimeout( + setTimeout(() => { + setCopiedTimeout(null); + }, 2000), + ); + }; + + return ( +
+ +
+ ( + + Public profile URL + + + + + {teamUrl && ( +

+ You can update the profile URL by updating the team URL in the general settings + page. +

+ )} + +
+ {!form.formState.errors.url && ( +
+ {field.value ? ( +
+ +
+ ) : ( +

A unique URL to access your profile

+ )} +
+ )} + + +
+
+ )} + /> + + { + const remaningLength = MAX_PROFILE_BIO_LENGTH - (field.value || '').length; + const pluralWord = Math.abs(remaningLength) === 1 ? 'character' : 'characters'; + + return ( + + Bio + +