diff --git a/.env.example b/.env.example index fea246621..3ce57722b 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,56 @@ +# [[AUTH]] NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="secret" +# [[APP]] NEXT_PUBLIC_SITE_URL="http://localhost:3000" NEXT_PUBLIC_APP_URL="http://localhost:3000" +# [[DATABASE]] NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" +# [[SMTP]] +# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels +NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" +# OPTIONAL: Defines the host to use for sending emails. +NEXT_PRIVATE_SMTP_HOST="127.0.0.1" +# OPTIONAL: Defines the port to use for sending emails. +NEXT_PRIVATE_SMTP_PORT=2500 +# OPTIONAL: Defines the username to use with the SMTP server. +NEXT_PRIVATE_SMTP_USERNAME="documenso" +# OPTIONAL: Defines the password to use with the SMTP server. +NEXT_PRIVATE_SMTP_PASSWORD="password" +# OPTIONAL: Defines the API key user to use with the SMTP server. +NEXT_PRIVATE_SMTP_APIKEY_USER= +# OPTIONAL: Defines the API key to use with the SMTP server. +NEXT_PRIVATE_SMTP_APIKEY= +# OPTIONAL: Defines whether to force the use of TLS. +NEXT_PRIVATE_SMTP_SECURE= +# REQUIRED: Defines the sender name to use for the from address. +NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso" +# REQUIRED: Defines the email address to use as the from address. +NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com" +# OPTIONAL: The API key to use for the MailChannels proxy endpoint. +NEXT_PRIVATE_MAILCHANNELS_API_KEY= +# OPTIONAL: The endpoint to use for the MailChannels API if using a proxy. +NEXT_PRIVATE_MAILCHANNELS_ENDPOINT= +# OPTIONAL: The domain to use for DKIM signing. +NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN= +# OPTIONAL: The selector to use for DKIM signing. +NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR= +# OPTIONAL: The private key to use for DKIM signing. +NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY= + +# [[STRIPE]] +NEXT_PRIVATE_STRIPE_API_KEY= +NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID= -NEXT_PRIVATE_STRIPE_API_KEY= -NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= - +# [[FEATURES]] NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false # This is only required for the marketing site +# [[REDIS]] NEXT_PRIVATE_REDIS_URL= NEXT_PRIVATE_REDIS_TOKEN= diff --git a/.gitignore b/.gitignore index d1595af42..cf058c04b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ yarn-error.log* # vercel .vercel + +# contentlayer +.contentlayer diff --git a/apps/marketing/content/blog/announcing-documenso.mdx b/apps/marketing/content/blog/announcing-documenso.mdx new file mode 100644 index 000000000..351690663 --- /dev/null +++ b/apps/marketing/content/blog/announcing-documenso.mdx @@ -0,0 +1,51 @@ +--- +title: Announcing Documenso +description: Launching an open-source document signing tool because trusted-based products should be built on openness. The first release will be in 2023. Sign up at documenso.com to be on board. +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2022-12-29 +tags: + - Announcement +--- + +
+ + +
Documenso — The DocuSign Open Source Alternative.
+
+ +## TL; DR; + +I am launching an open-source document signing tool because trust-based products should be built on openness. The first release will be in 2023. Sign up at documenso.com and get on board. + +## Let’s build the world’s most trusted document-signing tool. + +Today I am excited to announce my new Project Documenso. Documenso is an open-source document signing tool you can host yourself and freely build upon because it is, you know, open-source. Before I get more into the details of what and when will be launched I want to take a moment and talk about why. + +## Digital signing is great + +Signing Documents digitally has countless benefits: Less struggle with printing, less wasting paper, faster request delivery, easier changes, easier coordination of people far away, verifiable document integrity, and verifiable signer identity (this is a vast topic, will write more on soon), easier storage and search of signed documents, the list goes on. Digital Signatures take something very old and very trusted like personally signing documents into the digital space, adding the benefits listed above. It also introduces a new party to every signing transaction, the signing tool providers. What was peer to peer transaction before, now goes through an intermediary. While this is not a problem in itself, it should make us think about how we want these providers of trust to work. + +## How do we build trusted systems? + +While doing research for Documenso I came upon a quote that expresses the current state of document signing pretty well: + +> Document signing is NOT a technical problem. [Editor’s Note: Because it was solved technically a long time ago] It’s a legal acceptance problem — and everyone KNOWS DocuSign and friends and understands how they’re admissible. Anything else would have to compete with that and people would be suspicious of it for a long time. + +While this may sound like a hurdle at first, it immediately gave me a sense of validation for a more open approach to signing. People will and should be suspicious of their tools and demand a high bar when it comes to trust. And the way to earn this trust is by being open. Trusted tools should be the result of thoughtful discussion and reviews. They should be the result of the needs and will of its community. They should be transparent, adaptable, and empowering while using. Open-Source embodies these values very well for software, which makes it a perfect fit for this space and creating a high-trust tool. + +## Next Steps + +So, what can you expect from here on out? I have started to build Documenso 0.1 which is scheduled to release in “early” 2023. If you are interested in helping make this happen, let me know via hi@documenso.com. Getting working code into the hands of the perspective Documenso community is currently the #1 goal. Other than that I will be releasing several articles about document signing and what something like Documenso should look like, in my humble opinion. So stay tuned! + +If you think Documenso is worthy of support, please share documenso.com with anyone interested, and sign up to be among the first to try out version 0.1 as soon as it launches. + +Cheers from Hamburg + +Timur diff --git a/apps/marketing/content/blog/building-documenso-pt1.mdx b/apps/marketing/content/blog/building-documenso-pt1.mdx new file mode 100644 index 000000000..92c6f61ed --- /dev/null +++ b/apps/marketing/content/blog/building-documenso-pt1.mdx @@ -0,0 +1,98 @@ +--- +title: 'Building Documenso — Part 1: Certificates' +description: In today's fast-paced world, productivity and efficiency are crucial for success, both in personal and professional endeavors. We all strive to make the most of our time and energy to achieve our goals effectively. However, it's not always easy to stay on track and maintain peak performance. In this blog post, we'll explore 10 valuable tips to help you boost productivity and efficiency in your daily life. +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2023-06-23 +tags: + - Open Source + - Document Signature + - Certificates + - Signing +--- + +
+ + +
+ What actually is a signature? +
+
+ +> Disclaimer: I’m not a lawyer and this isn’t legal advice. We plan to publish a much more specific framework on the topic of signature validity. + +This is the first installment of the new Building Documenso series, where I describe the challenges and design choices that we make while building the world’s most open signing platform. + +As you may have heard, we launched the community-reviewed version 0.9 of Documenso on GitHub recently and it’s now available through the early adopter’s plan. One of the most fundamental choices we had to make on this first release, was the choice of certificate. While it’s interesting to know what we opted for, this shall also serve as a guide for everyone facing the same choice for self-hosting Documenso. + +> Question: Why do I need a document signing certificate to self-host? +> +> Short Answer: Inserting the images of a signature into the document is only part of the signing process. + +To have an actual digitally signed document you need a document signing certificate that is used to create the digital signature that is inserted into the document, alongside the visible one¹. + +When hosting a signature service yourself, as we do, there are four main choices for handling the certificate: Not using a certificate, creating your own, buying a trusted certificate, and becoming and trusted service provider to issue your own trusted certificate. + +## 1\. No Certificate + +A lot of signing services actually don’t employ actual digital signatures besides the inserted image. The only insert and image of the signatures into the document you sign. This can be done and is legally acceptable in many cases. This option isn’t directly supported by Documenso without changing the code. + +## 2\. Create your own + +Since the cryptography behind certificates is freely available as open source you could generate your own using OpenSSL for example. Since it’s hardly more work than option 1 (using Documenso at least), this would be my minimum effort recommendation. Having a self-created (“self-signed”) certificate doesn’t add much in terms of regulation but it guarantees the document’s integrity, meaning no changes have been made after signing². What this doesn’t give you, is the famous green checkmark in Adobe Acrobat. Why? Because you aren’t on the list of providers Adobe “trusts”.³ + +## 3\. Buy a “trusted” certificate. + +There are Certificate Authorities (CAs) that can sell you a certificate⁴. The service they provide is, that they validate your name (personal certificates) or your organization’s name (corporate certificate) before creating your certificate for you, just like you did in option 2. The difference is, that they are listed on the previously mentioned trust lists (e.g. Adobe’s) and thus the resulting signatures get a nice, green checkmark in Adobe Reader⁵ + +## 4\. Becoming a Trusted Certificate Authority (CA) yourself and create your own certificate + +This option is an incredibly complex endeavour, requiring a lot of effort and skill. It can be done, as there are multiple CAs around the world. Is it worth the effort? That depends a lot on what you’re trying to accomplish. + +
.  .  .
+ +## What we did + +Having briefly introduced the options, here is what we did: Since we aim to raise the bar on digital signature proliferation and trust, we opted to buy an “Advanced Personal Certificates for Companies/Organisations” from WiseKey. Thus, documents signed with Documenso’s hosted version look like this: + +
+ + +
The famous green checkmark: Signed by hosted Documenso
+
+ +There weren’t any deeper reasons we choose WiseKey, other than they offered what we needed and there wasn’t any reason to look much further. While I didn’t map the entire certificate market offering (yet), I’m pretty sure something similar could be found elsewhere. While we opted for option 3, choosing option 2 might be perfectly reasonable considering your use case.⁶ + +> While this is our setup, for now, we have a bigger plan for this topic. While globally trusted SSL Certificates have been available for free, courtesy of Let’s Encrypt, for a while now, there is no such thing as document signing. And there should be. Not having free and trusted infrastructure for signing is blocking a completely new generation of signing products from being created. This is why we’ll start working on option 4 when the time is right. + +Do you have questions or thoughts about this? As always, let me know in the comments, on twitter.com/eltimuro +or directly: documen.so/timur + +Join the self-hoster community here: https://documenso.slack.com/ + +Best from Hamburg + +Timur + +\[1\] There are different approaches to signing a document. For the sake of simplicity, here we talk about a document with X inserted signature images, that is afterward signed once the by signing service, i.e. Documenso. If each visual signature should have its own digital one (e.g. QES — eIDAS Level 3), the case is a bit more complex. + +\[2\] Of course, the signing service provider technically can change and resign the document, especially in the case mentioned in \[1\]. This can be countered by requiring actual digital signatures from each signer, that are bound to their identity/ account. Creating a completely trustless system in the context however is extremely hard to do and not the most pressing business need for the industry at this point, in my opinion. Though, this would be nice. + +\[3\] Adobe, like the EU, has a list of organizations they trust. The Adobe green checkmark is powered by the Adobe trust list, if you want to be trusted by EU standards here: https://ec.europa.eu/digital-building-blocks/DSS/webapp-demo/validation, you need to be on the EU trust list. Getting on each list is possible, though the latter is much more work. + +\[4\] Technically, they sign your certificate creation request (created by you), containing your info with their certificate (which is trusted), making your certificate trusted. This way, everything you sign with your certificate is seen as trusted. They created their certificate just like you, the difference is they are on the lists, mentioned in \[3\] + +\[5\] Why does Adobe get to say, what is trusted? They simply happen to have the most used pdf viewer. And since everyone checks there, whom they consider trusted carries weight. If it should be like this, is a different matter. + +\[6\] Self-Signed signatures, even purely visual signatures, are fully legally binding. Why you use changes mainly your confidence in the signature and the burden of proof. Also, some industries require a certain level of signatures e.g. retail loans (QES/ eIDAS Level 3 in the EU). diff --git a/apps/marketing/content/blog/the-documenso-manifest.mdx b/apps/marketing/content/blog/the-documenso-manifest.mdx new file mode 100644 index 000000000..8cace576d --- /dev/null +++ b/apps/marketing/content/blog/the-documenso-manifest.mdx @@ -0,0 +1,29 @@ +--- +title: The Documenso Manifest +description: Signing documents is a fundamental building block of private, economic, and government interactions. Access to easy and secure signing to participate in society should therefore be a fundamental right for everyone. The technology to enable this should be accessible and widespread. +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2023-07-13 +tags: + - Manifesto +--- + +
+ + +
+ Documenso — The DocuSign Open Source Alternative. +
+
+ +Signing documents is a fundamental building block of private, economic, and government interactions. Access to easy and secure signing to participate in society should therefore be a fundamental right for everyone. The technology to enable this should be accessible and widespread. + +We know that open source is the key to solving this need once and for all to benefit all humankind. Using open source kickstarts innovation by putting the open sharing of ideas and solutions first. With Documenso, we will create an open and globally accessible signing platform to empower users, customers, and developers to fulfill their needs. Documenso is built by and for the global community, listening and implementing what is needed. Being transparent with the code and the processes that use it brings trust and security to the platform. + +We build Documenso for longevity and scale by embracing the capital efficiency and inclusiveness of the Commercial Open Source (COSS) movement. We are building a global commodity for the world. diff --git a/apps/marketing/content/privacy.mdx b/apps/marketing/content/privacy.mdx new file mode 100644 index 000000000..fb8cc7e3f --- /dev/null +++ b/apps/marketing/content/privacy.mdx @@ -0,0 +1,256 @@ +--- +title: Privacy Policy +--- + +# Privacy Policy + +Effective date: 05/28/2023 + +### 1\. Introduction + +Welcome to **Documenso Inc.** + +Documenso Inc. (“us”, “we”, or “our”) operates [https://documenso.com](https://documenso.com) (hereinafter referred to as “ **Service**”). + +Our Privacy Policy governs your visit to [https://documenso.com](https://documenso.com), and explains how we collect, safeguard and disclose information that results from your use of our Service. + +We use your data to provide and improve Service. By using Service, you agree to the collection and use of information in accordance with this policy. Unless otherwise defined in this Privacy Policy, the terms used in this Privacy Policy have the same meanings as in our Terms and Conditions. + +Our Terms and Conditions (“**Terms**”) govern all use of our Service and together with the Privacy Policy constitutes your agreement with us (“ **agreement**”). + +### 2\. Definitions + +**SERVICE** means the https://documenso.com website operated by Documenso Inc. + +**PERSONAL DATA** means data about a living individual who can be identified from those data (or from those and other information either in our possession or likely to come into our possession). + +**USAGE DATA** is data collected automatically either generated by the use of Service or from Service infrastructure itself (for example, the duration of a page visit). + +**COOKIES** are small files stored on your device (computer or mobile device). + +**DATA CONTROLLER** means a natural or legal person who (either alone or jointly or in common with other persons) determines the purposes for which and the manner in which any personal data are, or are to be, processed. For the purpose of this Privacy Policy, we are a Data Controller of your data. + +**DATA PROCESSORS (OR SERVICE PROVIDERS)** means any natural or legal person who processes the data on behalf of the Data Controller. We may use the services of various Service Providers in order to process your data more effectively. + +**DATA SUBJECT** is any living individual who is the subject of Personal Data. + +**THE USER** is the individual using our Service. The User corresponds to the Data Subject, who is the subject of Personal Data. + +### 3\. Information Collection and Use + +We collect several different types of information for various purposes to provide and improve our Service to you. + +### 4\. Types of Data Collected + +**Personal Data** + +While using our Service, we may ask you to provide us with certain personally identifiable information that can be used to contact or identify you (“**Personal Data**”). Personally identifiable information may include, but is not limited to: + +1. Email address +2. First name and last name +3. Cookies and Usage Data + +We may use your Personal Data to contact you with newsletters, marketing or promotional materials and other information that may be of interest to you. You may opt out of receiving any, or all, of these communications from us by following the unsubscribe link. + +**Usage Data** + +We may also collect information that your browser sends whenever you visit our Service or when you access Service by or through a mobile device (“**Usage Data**”). + +This Usage Data may include information such as your computer's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that you visit, the time and date of your visit, the time spent on those pages, unique device identifiers and other diagnostic data. + +When you access Service with a mobile device, this Usage Data may include information such as the type of mobile device you use, your mobile device unique ID, the IP address of your mobile device, your mobile operating system, the type of mobile Internet browser you use, unique device identifiers and other diagnostic data. + +**Tracking Cookies Data** + +We use cookies and similar tracking technologies to track the activity on our Service and we hold certain information. + +Cookies are files with a small amount of data which may include an anonymous unique identifier. Cookies are sent to your browser from a website and stored on your device. Other tracking technologies are also used such as beacons, tags and scripts to collect and track information and to improve and analyze our Service. + +You can instruct your browser to refuse all cookies or to indicate when a cookie is being sent. However, if you do not accept cookies, you may not be able to use some portions of our Service. + +Examples of Cookies we use: + +1. **Session Cookies:** We use Session Cookies to operate our Service. +2. **Preference Cookies:** We use Preference Cookies to remember your preferences and various settings. +3. **Security Cookies:** We use Security Cookies for security purposes. +4. **Advertising Cookies:** Advertising Cookies are used to serve you with advertisements that may be relevant to you and your interests. + +### 5\. Use of Data + +Documenso Inc. uses the collected data for various purposes: + +1. to provide and maintain our Service; +2. to notify you about changes to our Service; +3. to allow you to participate in interactive features of our Service when you choose to do so; +4. to provide customer support; +5. to gather analysis or valuable information so that we can improve our Service; +6. to monitor the usage of our Service; +7. to detect, prevent and address technical issues; +8. to fulfill any other purpose for which you provide it; +9. to carry out our obligations and enforce our rights arising from any contracts entered into between you and us, including for billing and collection; +10. to provide you with notices about your account and/or subscription, including expiration and renewal notices, email-instructions, etc.; +11. to provide you with news, special offers and general information about other goods, services and events which we offer that are similar to those that you have already purchased or enquired about unless you have opted not to receive such information; +12. in any other way we may describe when you provide the information; +13. for any other purpose with your consent. + +### 6\. Retention of Data + +We will retain your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use your Personal Data to the extent necessary to comply with our legal obligations (for example, if we are required to retain your data to comply with applicable laws), resolve disputes, and enforce our legal agreements and policies. + +We will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period, except when this data is used to strengthen the security or to improve the functionality of our Service, or we are legally obligated to retain this data for longer time periods. + +### 7\. Transfer of Data + +Your information, including Personal Data, may be transferred to – and maintained on – computers located outside of your state, province, country or other governmental jurisdiction where the data protection laws may differ from those of your jurisdiction. + +If you are located outside United States and choose to provide information to us, please note that we transfer the data, including Personal Data, to United States and process it there. + +Your consent to this Privacy Policy followed by your submission of such information represents your agreement to that transfer. + +Documenso Inc. will take all the steps reasonably necessary to ensure that your data is treated securely and in accordance with this Privacy Policy and no transfer of your Personal Data will take place to an organisation or a country unless there are adequate controls in place including the security of your data and other personal information. + +### 8\. Disclosure of Data + +We may disclose personal information that we collect, or you provide: + +1. **Disclosure for Law Enforcement.** +2. Under certain circumstances, we may be required to disclose your Personal Data if required to do so by law or in response to valid requests by public authorities. +3. **Business Transaction.** +4. If we or our subsidiaries are involved in a merger, acquisition or asset sale, your Personal Data may be transferred. +5. **Other cases. We may disclose your information also:** + 1. to our subsidiaries and affiliates; + 2. to contractors, service providers, and other third parties we use to support our business; + 3. to fulfill the purpose for which you provide it; + +### 9\. Security of Data + +The security of your data is important to us but remember that no method of transmission over the Internet or method of electronic storage is 100% secure. While we strive to use commercially acceptable means to protect your Personal Data, we cannot guarantee its absolute security. + +### 10\. Your Data Protection Rights Under General Data Protection Regulation (GDPR) + +If you are a resident of the European Union (EU) and European Economic Area (EEA), you have certain data protection rights, covered by GDPR. – See more at [https://eur-lex.europa.eu/eli/reg/2016/679/oj](https://eur-lex.europa.eu/eli/reg/2016/679/oj) + +We aim to take reasonable steps to allow you to correct, amend, delete, or limit the use of your Personal Data. + +If you wish to be informed what Personal Data we hold about you and if you want it to be removed from our systems, please email us at hi@documenso.com. + +In certain circumstances, you have the following data protection rights: + +1. the right to access, update or to delete the information we have on you; +2. the right of rectification. You have the right to have your information rectified if that information is inaccurate or incomplete; +3. the right to object. You have the right to object to our processing of your Personal Data; +4. the right of restriction. You have the right to request that we restrict the processing of your personal information; +5. the right to data portability. You have the right to be provided with a copy of your Personal Data in a structured, machine-readable and commonly used format; +6. the right to withdraw consent. You also have the right to withdraw your consent at any time where we rely on your consent to process your personal information; + +Please note that we may ask you to verify your identity before responding to such requests. Please note, we may not able to provide Service without some necessary data. + +You have the right to complain to a Data Protection Authority about our collection and use of your Personal Data. For more information, please contact your local data protection authority in the European Economic Area (EEA). + +### 11\. Your Data Protection Rights under the California Privacy Protection Act (CalOPPA) + +CalOPPA is the first state law in the nation to require commercial websites and online services to post a privacy policy. The law’s reach stretches well beyond California to require a person or company in the United States (and conceivable the world) that operates websites collecting personally identifiable information from California consumers to post a conspicuous privacy policy on its website stating exactly the information being collected and those individuals with whom it is being shared, and to comply with this policy. – See more at: [https://consumercal.org/about-cfc/cfc-education-foundation/california-online-privacy-protection-act-caloppa-3/](https://consumercal.org/about-cfc/cfc-education-foundation/california-online-privacy-protection-act-caloppa-3/) + +According to CalOPPA we agree to the following: + +1. users can visit our site anonymously; +2. our Privacy Policy link includes the word “Privacy”, and can easily be found on the page specified above on the home page of our website; +3. users will be notified of any privacy policy changes on our Privacy Policy Page; +4. users are able to change their personal information by emailing us at hi@documenso.com. + +Our Policy on “Do Not Track” Signals: + +We honor Do Not Track signals and do not track, plant cookies, or use advertising when a Do Not Track browser mechanism is in place. Do Not Track is a preference you can set in your web browser to inform websites that you do not want to be tracked. + +You can enable or disable Do Not Track by visiting the Preferences or Settings page of your web browser. + +### 12\. Your Data Protection Rights under the California Consumer Privacy Act (CCPA) + +If you are a California resident, you are entitled to learn what data we collect about you, ask to delete your data and not to sell (share) it. To exercise your data protection rights, you can make certain requests and ask us: + +1. **What personal information we have about you**. If you make this request, we will return to you: + + 1. The categories of personal information we have collected about you. + 2. The categories of sources from which we collect your personal information. + 3. The business or commercial purpose for collecting or selling your personal information. + 4. The categories of third parties with whom we share personal information. + 5. The specific pieces of personal information we have collected about you. + 6. A list of categories of personal information that we have sold, along with the category of any other company we sold it to. If we have not sold your personal information, we will inform you of that fact. + 7. A list of categories of personal information that we have disclosed for a business purpose, along with the category of any other company we shared it with. + + Please note, you are entitled to ask us to provide you with this information up to two times in a rolling twelve-month period. When you make this request, the information provided may be limited to the personal information we collected about you in the previous 12 months. + +2. **To delete your personal information**. If you make this request, we will delete the personal information we hold about you as of the date of your request from our records and direct any service providers to do the same. In some cases, deletion may be accomplished through de-identification of the information. If you choose to delete your personal information, you may not be able to use certain functions that require your personal information to operate. +3. **To stop selling your personal information**. We don't sell or rent your personal information to any third parties for any purpose. You are the only owner of your Personal Data and can request disclosure or deletion at any time. + +Please note, if you ask us to delete or stop selling your data, it may impact your experience with us, and you may not be able to participate in certain programs or membership services which require the usage of your personal information to function. But in no circumstances, we will discriminate against you for exercising your rights. + +To exercise your California data protection rights described above, please send your request(s) by one of the following means: + +By email: hi@documenso.com + +Your data protection rights, described above, are covered by the CCPA, short for the California Consumer Privacy Act. To find out more, visit the official [California Legislative Information website](https://leginfo.legislature.ca.gov/faces/billTextClient.xhtml?bill_id=201720180AB375). The CCPA took effect on 01/01/2020. + +### 13\. Service Providers + +We may employ third party companies and individuals to facilitate our Service (“ **Service Providers**”), provide Service on our behalf, perform Service-related services or assist us in analysing how our Service is used. + +These third parties have access to your Personal Data only to perform these tasks on our behalf and are obligated not to disclose or use it for any other purpose. + +### 14\. Analytics + +We may use third-party Service Providers to monitor and analyze the use of our Service. + +**Plausible Analytics** + +Plausible Analytics is an analytics service provided by Conva Ventures Inc. You can find their Privacy Policy here: [https://plausible.io/privacy](https://plausible.io/privacy) + +### 15\. CI/CD tools + +We may use third-party Service Providers to automate the development process of our Service. + +**GitHub** + +GitHub is provided by GitHub, Inc. + +GitHub is a development platform to host and review code, manage projects, and build software. + +For more information on what data GitHub collects for what purpose and how the protection of the data is ensured, please visit GitHub Privacy Policy page: [https://help.github.com/en/articles/github-privacy-statement](https://help.github.com/en/articles/github-privacy-statement) . + +### 16\. Payments + +We may provide paid products and/or services within Service. In that case, we use third-party services for payment processing (e.g. payment processors). + +We will not store or collect your payment card details. That information is provided directly to our third-party payment processors whose use of your personal information is governed by their Privacy Policy. These payment processors adhere to the standards set by PCI-DSS as managed by the PCI Security Standards Council, which is a joint effort of brands like Visa, Mastercard, American Express and Discover. PCI-DSS requirements help ensure the secure handling of payment information. + +The payment processors we work with are: + +**Stripe:** + +Their Privacy Policy can be viewed at: [https://stripe.com/us/privacy](https://stripe.com/us/privacy) + +### 17\. Links to Other Sites + +Our Service may contain links to other sites that are not operated by us. If you click a third party link, you will be directed to that third party's site. We strongly advise you to review the Privacy Policy of every site you visit. + +We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services. + +### 18\. Children's Privacy + +Our Services are not intended for use by children under the age of 18 (“ **Child**” or “**Children**”). + +We do not knowingly collect personally identifiable information from Children under 18. If you become aware that a Child has provided us with Personal Data, please contact us. If we become aware that we have collected Personal Data from Children without verification of parental consent, we take steps to remove that information from our servers. + +### 19\. Changes to This Privacy Policy + +We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page. + +We will let you know via email and/or a prominent notice on our Service, prior to the change becoming effective and update “effective date” at the top of this Privacy Policy. + +You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page. + +### 20\. Contact Us + +If you have any questions about this Privacy Policy, please contact us: + +By email: hi@documenso.com. diff --git a/apps/marketing/contentlayer.config.ts b/apps/marketing/contentlayer.config.ts new file mode 100644 index 000000000..f1ba82b89 --- /dev/null +++ b/apps/marketing/contentlayer.config.ts @@ -0,0 +1,33 @@ +import { defineDocumentType, makeSource } from 'contentlayer/source-files'; + +export const BlogPost = defineDocumentType(() => ({ + name: 'BlogPost', + filePathPattern: `blog/**/*.mdx`, + contentType: 'mdx', + fields: { + title: { type: 'string', required: true }, + description: { type: 'string', required: true }, + date: { type: 'date', required: true }, + tags: { type: 'list', of: { type: 'string' }, required: false, default: [] }, + authorName: { type: 'string', required: true }, + authorImage: { type: 'string', required: false }, + authorRole: { type: 'string', required: true }, + }, + computedFields: { + href: { type: 'string', resolve: (post) => `/${post._raw.flattenedPath}` }, + }, +})); + +export const GenericPage = defineDocumentType(() => ({ + name: 'GenericPage', + filePathPattern: '**/*.mdx', + contentType: 'mdx', + fields: { + title: { type: 'string', required: true }, + }, + computedFields: { + href: { type: 'string', resolve: (post) => `/${post._raw.flattenedPath}` }, + }, +})); + +export default makeSource({ contentDirPath: 'content', documentTypes: [BlogPost, GenericPage] }); diff --git a/apps/marketing/next.config.js b/apps/marketing/next.config.js index b57b41780..ee7d10899 100644 --- a/apps/marketing/next.config.js +++ b/apps/marketing/next.config.js @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); +const { withContentlayer } = require('next-contentlayer'); const { parsed: env } = require('dotenv').config({ path: path.join(__dirname, '../../.env.local'), @@ -12,4 +13,4 @@ const config = { env, }; -module.exports = config; +module.exports = withContentlayer(config); diff --git a/apps/marketing/package.json b/apps/marketing/package.json index e34c66b99..11e9fe60d 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -15,11 +15,13 @@ "@documenso/trpc": "*", "@documenso/ui": "*", "@hookform/resolvers": "^3.1.0", + "contentlayer": "^0.3.4", "framer-motion": "^10.12.8", "lucide-react": "^0.214.0", "micro": "^10.0.1", - "next": "13.4.1", + "next": "13.4.9", "next-auth": "^4.22.1", + "next-contentlayer": "^0.3.4", "next-plausible": "^3.7.2", "perfect-freehand": "^1.2.0", "react": "18.2.0", diff --git a/apps/marketing/public/blog/blog-author-timur.jpeg b/apps/marketing/public/blog/blog-author-timur.jpeg new file mode 100644 index 000000000..841a01473 Binary files /dev/null and b/apps/marketing/public/blog/blog-author-timur.jpeg differ diff --git a/apps/marketing/public/blog/blog-banner-announcing-documenso.webp b/apps/marketing/public/blog/blog-banner-announcing-documenso.webp new file mode 100644 index 000000000..26f2cdb0a Binary files /dev/null and b/apps/marketing/public/blog/blog-banner-announcing-documenso.webp differ diff --git a/apps/marketing/public/blog/blog-banner-building-documenso.webp b/apps/marketing/public/blog/blog-banner-building-documenso.webp new file mode 100644 index 000000000..be9d785a8 Binary files /dev/null and b/apps/marketing/public/blog/blog-banner-building-documenso.webp differ diff --git a/apps/marketing/public/blog/blog-banner-manifest.jpeg b/apps/marketing/public/blog/blog-banner-manifest.jpeg new file mode 100644 index 000000000..1df984ffa Binary files /dev/null and b/apps/marketing/public/blog/blog-banner-manifest.jpeg differ diff --git a/apps/marketing/public/blog/blog-fig-building-documenso.webp b/apps/marketing/public/blog/blog-fig-building-documenso.webp new file mode 100644 index 000000000..4d2738183 Binary files /dev/null and b/apps/marketing/public/blog/blog-fig-building-documenso.webp differ diff --git a/apps/marketing/src/app/(marketing)/[content]/page.tsx b/apps/marketing/src/app/(marketing)/[content]/page.tsx new file mode 100644 index 000000000..f32765024 --- /dev/null +++ b/apps/marketing/src/app/(marketing)/[content]/page.tsx @@ -0,0 +1,46 @@ +import Image from 'next/image'; +import { notFound } from 'next/navigation'; + +import { allDocuments } from 'contentlayer/generated'; +import type { MDXComponents } from 'mdx/types'; +import { useMDXComponent } from 'next-contentlayer/hooks'; + +export const generateStaticParams = async () => + allDocuments.map((post) => ({ post: post._raw.flattenedPath })); + +export const generateMetadata = ({ params }: { params: { content: string } }) => { + const document = allDocuments.find((post) => post._raw.flattenedPath === params.content); + + if (!document) { + notFound(); + } + + return { title: `Documenso - ${document.title}` }; +}; + +const mdxComponents: MDXComponents = { + MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => ( + {props.alt + ), +}; + +/** + * A generic catch all page for the root level that checks for content layer documents. + * + * Will render the document if it exists, otherwise will return a 404. + */ +export default function ContentPage({ params }: { params: { content: string } }) { + const post = allDocuments.find((post) => post._raw.flattenedPath === params.content); + + if (!post) { + notFound(); + } + + const MDXContent = useMDXComponent(post.body.code); + + return ( +
+ +
+ ); +} diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx new file mode 100644 index 000000000..68f22e734 --- /dev/null +++ b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx @@ -0,0 +1,88 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; + +import { allBlogPosts } from 'contentlayer/generated'; +import { ChevronLeft } from 'lucide-react'; +import type { MDXComponents } from 'mdx/types'; +import { useMDXComponent } from 'next-contentlayer/hooks'; + +export const generateStaticParams = async () => + allBlogPosts.map((post) => ({ post: post._raw.flattenedPath })); + +export const generateMetadata = ({ params }: { params: { post: string } }) => { + const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`); + + if (!blogPost) { + notFound(); + } + + return { title: `Documenso - ${blogPost.title}` }; +}; + +const mdxComponents: MDXComponents = { + MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => ( + {props.alt + ), +}; + +export default function BlogPostPage({ params }: { params: { post: string } }) { + const post = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`); + + if (!post) { + notFound(); + } + + const MDXContent = useMDXComponent(post.body.code); + + return ( +
+
+ + +

{post.title}

+ +
+
+ {post.authorImage && ( + {`Image + )} +
+ +
+

{post.authorName}

+

{post.authorRole}

+
+
+
+ + + + {post.tags.length > 0 && ( + + )} + +
+ + + + Back to all posts + +
+ ); +} diff --git a/apps/marketing/src/app/(marketing)/blog/page.tsx b/apps/marketing/src/app/(marketing)/blog/page.tsx new file mode 100644 index 000000000..53ec852d0 --- /dev/null +++ b/apps/marketing/src/app/(marketing)/blog/page.tsx @@ -0,0 +1,80 @@ +import { allBlogPosts } from 'contentlayer/generated'; + +export default function BlogPage() { + const blogPosts = allBlogPosts.sort((a, b) => { + const dateA = new Date(a.date); + const dateB = new Date(b.date); + + return dateB.getTime() - dateA.getTime(); + }); + + return ( +
+
+

From the blog

+ +

+ Get the latest news from Documenso, including product updates, team announcements and + more! +

+
+ +
+ {blogPosts.map((post, i) => ( +
+
+ + + {post.tags.length > 0 && ( +
    + {post.tags.map((tag, j) => ( +
  • + {tag} +
  • + ))} +
+ )} +
+ +
+

+ + + {post.title} + +

+

+ {post.description} +

+
+ +
+
+ {post.authorImage && ( + {`Image + )} +
+ +
+

{post.authorName}

+

{post.authorRole}

+
+
+
+ ))} +
+
+ ); +} diff --git a/apps/marketing/src/app/(marketing)/oss-friends/page.tsx b/apps/marketing/src/app/(marketing)/oss-friends/page.tsx new file mode 100644 index 000000000..4bd038ec2 --- /dev/null +++ b/apps/marketing/src/app/(marketing)/oss-friends/page.tsx @@ -0,0 +1,204 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; + +import { Variants, motion } from 'framer-motion'; + +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card'; + +import backgroundPattern from '~/assets/background-pattern.png'; + +const OSSFriends = [ + { + name: 'BoxyHQ', + description: + 'BoxyHQ’s suite of APIs for security and privacy helps engineering teams build and ship compliant cloud applications faster.', + href: 'https://boxyhq.com', + }, + { + name: 'Cal.com', + description: + 'Cal.com is a scheduling tool that helps you schedule meetings without the back-and-forth emails.', + href: 'https://cal.com', + }, + { + name: 'Crowd.dev', + description: + 'Centralize community, product, and customer data to understand which companies are engaging with your open source project.', + href: 'https://www.crowd.dev', + }, + { + name: 'Documenso', + description: + 'The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.', + href: 'https://documenso.com', + }, + { + name: 'Erxes', + description: + 'The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences ​​that work for all types of business.', + href: 'https://erxes.io', + }, + { + name: 'Formbricks', + description: + 'Survey granular user segments at any point in the user journey. Gather up to 6x more insights with targeted micro-surveys. All open-source.', + href: 'https://formbricks.com', + }, + { + name: 'Forward Email', + description: + 'Free email forwarding for custom domains. For 6 years and counting, we are the go-to email service for thousands of creators, developers, and businesses.', + href: 'https://forwardemail.net', + }, + { + name: 'GitWonk', + description: + 'GitWonk is an open-source technical documentation tool, designed and built focusing on the developer experience.', + href: 'https://gitwonk.com', + }, + { + name: 'Hanko', + description: + 'Open-source authentication and user management for the passkey era. Integrated in minutes, for web and mobile apps.', + href: 'https://www.hanko.io', + }, + { + name: 'HTMX', + description: + 'HTMX is a dependency-free JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML.', + href: 'https://htmx.org', + }, + { + name: 'Infisical', + description: + 'Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.', + href: 'https://infisical.com', + }, + { + name: 'Novu', + description: + 'The open-source notification infrastructure for developers. Simple components and APIs for managing all communication channels in one place.', + href: 'https://novu.co', + }, + { + name: 'OpenBB', + description: + 'Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.', + href: 'https://openbb.co', + }, + { + name: 'Sniffnet', + description: + 'Sniffnet is a network monitoring tool to help you easily keep track of your Internet traffic.', + href: 'https://www.sniffnet.net', + }, + { + name: 'Typebot', + description: + 'Typebot gives you powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.', + href: 'https://typebot.io', + }, + { + name: 'Webiny', + description: + 'Open-source enterprise-grade serverless CMS. Own your data. Scale effortlessly. Customize everything.', + href: 'https://www.webiny.com', + }, + { + name: 'Webstudio', + description: 'Webstudio is an open source alternative to Webflow', + href: 'https://webstudio.is', + }, +]; + +const ContainerVariants: Variants = { + initial: { + opacity: 0, + }, + animate: { + opacity: 1, + transition: { + staggerChildren: 0.075, + }, + }, +}; + +const CardVariants: Variants = { + initial: { + opacity: 0, + y: 50, + }, + animate: { + opacity: 1, + y: 0, + transition: { + duration: 0.5, + }, + }, +}; + +const randomDegrees = () => { + const degrees = [45, 120, -140, -45]; + + return degrees[Math.floor(Math.random() * degrees.length)]; +}; + +export default function OSSFriendsPage() { + return ( +
+
+

+ Our OSS Friends +

+ +

+ We love open source and so should you, below you can find a list of our friends who are + just as passionate about open source as we are. +

+
+ + + {OSSFriends.map((friend, index) => ( + + + + + {friend.name} + + +

{friend.description}

+ +
+ + + +
+
+
+
+ ))} +
+ +
+ background pattern +
+
+ ); +} diff --git a/apps/marketing/src/app/layout.tsx b/apps/marketing/src/app/layout.tsx index 1517e4985..ea21ed3c3 100644 --- a/apps/marketing/src/app/layout.tsx +++ b/apps/marketing/src/app/layout.tsx @@ -43,9 +43,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - - {children} - + {children} diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index 823ece92e..aba8d17ce 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -46,6 +46,10 @@ export const Footer = ({ className, ...props }: FooterProps) => {
+ + Blog + + { Support - {/* Privacy - */} +
diff --git a/apps/marketing/src/components/(marketing)/header.tsx b/apps/marketing/src/components/(marketing)/header.tsx index 5a1fa3b89..4c31408f2 100644 --- a/apps/marketing/src/components/(marketing)/header.tsx +++ b/apps/marketing/src/components/(marketing)/header.tsx @@ -15,6 +15,10 @@ export const Header = ({ className, ...props }: HeaderProps) => {
+ + Blog + + Pricing diff --git a/apps/marketing/src/pages/api/stripe/webhook/index.ts b/apps/marketing/src/pages/api/stripe/webhook/index.ts index 5db47c89a..a0a4ccebb 100644 --- a/apps/marketing/src/pages/api/stripe/webhook/index.ts +++ b/apps/marketing/src/pages/api/stripe/webhook/index.ts @@ -124,16 +124,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) document.document = await insertImageInPDF( document.document, signatureDataUrl, - field.positionX, - field.positionY, + Number(field.positionX), + Number(field.positionY), field.page, ); } else { document.document = await insertTextInPDF( document.document, signatureText ?? '', - field.positionX, - field.positionY, + Number(field.positionX), + Number(field.positionY), field.page, ); } diff --git a/apps/marketing/tsconfig.json b/apps/marketing/tsconfig.json index 47acf99dc..5223d6b2b 100644 --- a/apps/marketing/tsconfig.json +++ b/apps/marketing/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@documenso/tsconfig/nextjs.json", "compilerOptions": { + "baseUrl": ".", "allowJs": true, "plugins": [ { @@ -8,9 +9,8 @@ } ], "paths": { - "~/*": [ - "./src/*" - ] + "~/*": ["./src/*"], + "contentlayer/generated": ["./.contentlayer/generated"] }, "strictNullChecks": true }, @@ -18,9 +18,8 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx", - ".next/types/**/*.ts" + ".next/types/**/*.ts", + ".contentlayer/generated" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] } diff --git a/apps/web/next.config.js b/apps/web/next.config.js index b57b41780..09760f806 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -7,9 +7,23 @@ const { parsed: env } = require('dotenv').config({ /** @type {import('next').NextConfig} */ const config = { + experimental: { + serverActions: true, + }, reactStrictMode: true, - transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'], + transpilePackages: [ + '@documenso/lib', + '@documenso/prisma', + '@documenso/trpc', + '@documenso/ui', + '@documenso/email', + ], env, + modularizeImports: { + 'lucide-react': { + transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}', + }, + }, }; module.exports = config; diff --git a/apps/web/package.json b/apps/web/package.json index c7db16aa6..32d0d61b3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,8 @@ "framer-motion": "^10.12.8", "lucide-react": "^0.214.0", "micro": "^10.0.1", - "next": "13.4.1", + "nanoid": "^4.0.2", + "next": "13.4.9", "next-auth": "^4.22.1", "next-plausible": "^3.7.2", "next-themes": "^0.2.1", @@ -32,6 +33,7 @@ "react-hook-form": "^7.43.9", "react-icons": "^4.8.0", "react-pdf": "^7.1.1", + "react-rnd": "^10.4.1", "typescript": "5.0.4", "zod": "^3.21.4" }, diff --git a/apps/web/public/static/clock.png b/apps/web/public/static/clock.png new file mode 100644 index 000000000..ce8ee6481 Binary files /dev/null and b/apps/web/public/static/clock.png differ diff --git a/apps/web/public/static/completed.png b/apps/web/public/static/completed.png new file mode 100644 index 000000000..11a10f445 Binary files /dev/null and b/apps/web/public/static/completed.png differ diff --git a/apps/web/public/static/document.png b/apps/web/public/static/document.png new file mode 100644 index 000000000..8baa22639 Binary files /dev/null and b/apps/web/public/static/document.png differ diff --git a/apps/web/public/static/download.png b/apps/web/public/static/download.png new file mode 100644 index 000000000..c376d6051 Binary files /dev/null and b/apps/web/public/static/download.png differ diff --git a/apps/web/public/static/logo.png b/apps/web/public/static/logo.png new file mode 100644 index 000000000..4813aaaef Binary files /dev/null and b/apps/web/public/static/logo.png differ diff --git a/apps/web/public/static/review.png b/apps/web/public/static/review.png new file mode 100644 index 000000000..84bec4f62 Binary files /dev/null and b/apps/web/public/static/review.png differ diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx index b6bad38a9..a9d650eb6 100644 --- a/apps/web/src/app/(dashboard)/dashboard/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/page.tsx @@ -14,6 +14,7 @@ import { TableRow, } from '@documenso/ui/primitives/table'; +import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card'; import { DocumentStatus } from '~/components/formatter/document-status'; import { LocaleDate } from '~/components/formatter/locale-date'; @@ -38,9 +39,15 @@ export default async function DashboardPage() {

Dashboard

- - - + + + + + + + + +
@@ -54,30 +61,38 @@ export default async function DashboardPage() { ID Title + Reciepient Status Created - {results.data.map((document) => ( - - {document.id} - - - {document.title} - - - - - - - - - - ))} + {results.data.map((document) => { + return ( + + {document.id} + + + {document.title} + + + + + + + + + + + + + + + ); + })} {results.data.length === 0 && ( diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx new file mode 100644 index 000000000..b79c46671 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useState } from 'react'; + +import dynamic from 'next/dynamic'; + +import { Loader } from 'lucide-react'; + +import { Document, Field, Recipient, User } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +import { AddFieldsFormPartial } from '~/components/forms/edit-document/add-fields'; +import { AddSignersFormPartial } from '~/components/forms/edit-document/add-signers'; +import { AddSubjectFormPartial } from '~/components/forms/edit-document/add-subject'; + +const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), { + ssr: false, + loading: () => ( +
+ + +

Loading document...

+
+ ), +}); + +export type EditDocumentFormProps = { + className?: string; + user: User; + document: Document; + recipients: Recipient[]; + fields: Field[]; +}; + +export const EditDocumentForm = ({ + className, + document, + recipients, + fields, + user: _user, +}: EditDocumentFormProps) => { + const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers'); + + const documentUrl = `data:application/pdf;base64,${document.document}`; + + const onNextStep = () => { + if (step === 'signers') { + setStep('fields'); + } + + if (step === 'fields') { + setStep('subject'); + } + }; + + const onPreviousStep = () => { + if (step === 'fields') { + setStep('signers'); + } + + if (step === 'subject') { + setStep('fields'); + } + }; + + return ( +
+ + + + + + +
+ {step === 'signers' && ( + + )} + + {step === 'fields' && ( + + )} + + {step === 'subject' && ( + + )} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx b/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx index b2008c921..5daa07e9e 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx @@ -16,7 +16,7 @@ export type LoadablePDFCard = PDFViewerProps & { const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), { ssr: false, loading: () => ( -
+

Loading document...

diff --git a/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx b/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx new file mode 100644 index 000000000..e97489a6e --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx @@ -0,0 +1,28 @@ +import Link from 'next/link'; + +import { ChevronLeft, Loader } from 'lucide-react'; + +export default function Loading() { + return ( +
+ + + Documents + +

+ Loading Document... +

+
+
+
+ + +

Loading document...

+
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index e84d463e4..94fb8ad96 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -1,12 +1,15 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { ChevronLeft } from 'lucide-react'; +import { ChevronLeft, Users2 } from 'lucide-react'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +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 { EditDocumentForm } from '~/components/forms/edit-document'; +import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; +import { DocumentStatus } from '~/components/formatter/document-status'; export type DocumentPageProps = { params: { @@ -34,11 +37,22 @@ export default async function DocumentPage({ params }: DocumentPageProps) { redirect('/documents'); } + const [recipients, fields] = await Promise.all([ + await getRecipientsForDocument({ + documentId, + userId: session.id, + }), + await getFieldsForDocument({ + documentId, + userId: session.id, + }), + ]); + return (
- Dashboard + Documents

- +
+ + + {recipients.length > 0 && ( +
+ + + {recipients.length} Recipient(s) +
+ )} +
+ +

); } diff --git a/apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx new file mode 100644 index 000000000..c2f90ae7a --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx @@ -0,0 +1,18 @@ +import Link from 'next/link'; + +import { ChevronLeft } from 'lucide-react'; + +export default function DocumentSentPage() { + return ( +
+ + + Documents + + +

+ Loading Document... +

+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index 58b6eb1ac..35fdfb4b1 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -8,15 +8,16 @@ import { Loader } from 'lucide-react'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { FindResultSet } from '@documenso/lib/types/find-result-set'; -import { Document } from '@documenso/prisma/client'; +import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { DocumentStatus } from '~/components/formatter/document-status'; import { LocaleDate } from '~/components/formatter/locale-date'; export type DocumentsDataTableProps = { - results: FindResultSet; + results: FindResultSet; }; export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { @@ -49,6 +50,13 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { ), }, + { + header: 'Recipient', + accessorKey: 'recipient', + cell: ({ row }) => { + return ; + }, + }, { header: 'Status', accessorKey: 'status', diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 9ce82a4c0..9c60bbadf 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import { Inter } from 'next/font/google'; import { TrpcProvider } from '@documenso/trpc/react'; import { Toaster } from '@documenso/ui/primitives/toaster'; +import { TooltipProvider } from '@documenso/ui/primitives/tooltip'; import { ThemeProvider } from '~/providers/next-theme'; import { PlausibleProvider } from '~/providers/plausible'; @@ -47,7 +48,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - {children} + + {children} + diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx new file mode 100644 index 000000000..e79a2e71b --- /dev/null +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx @@ -0,0 +1,51 @@ +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; + +const ZIndexes: { [key: string]: string } = { + '10': 'z-10', + '20': 'z-20', + '30': 'z-30', + '40': 'z-40', + '50': 'z-50', +}; + +export type StackAvatarProps = { + first?: boolean; + zIndex?: string; + fallbackText?: string; + type: 'unsigned' | 'waiting' | 'completed'; +}; + +export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => { + let classes = ''; + let zIndexClass = ''; + const firstClass = first ? '' : '-ml-3'; + + if (zIndex) { + zIndexClass = ZIndexes[zIndex] ?? ''; + } + + switch (type) { + case 'unsigned': + classes = 'bg-dawn-200 text-dawn-900'; + break; + case 'waiting': + classes = 'bg-water text-water-700'; + break; + case 'completed': + classes = 'bg-documenso-200 text-documenso-800'; + break; + default: + break; + } + + return ( + + {fallbackText ?? 'UK'} + + ); +}; diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx new file mode 100644 index 000000000..dbd1dc712 --- /dev/null +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -0,0 +1,90 @@ +import { initials } from '@documenso/lib/client-only/recipient-initials'; +import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { Recipient } from '@documenso/prisma/client'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@documenso/ui/primitives/tooltip'; + +import { StackAvatar } from './stack-avatar'; +import { StackAvatars } from './stack-avatars'; + +export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[] }) => { + const waitingRecipients = recipients.filter( + (recipient) => recipient.sendStatus === 'SENT' && recipient.signingStatus === 'NOT_SIGNED', + ); + + const completedRecipients = recipients.filter( + (recipient) => recipient.sendStatus === 'SENT' && recipient.signingStatus === 'SIGNED', + ); + + const uncompletedRecipients = recipients.filter( + (recipient) => recipient.sendStatus === 'NOT_SENT' && recipient.signingStatus === 'NOT_SIGNED', + ); + + return ( + + + + + + +
+ {completedRecipients.length > 0 && ( +
+

Completed

+ {completedRecipients.map((recipient: Recipient) => ( +
+ + {recipient.email} +
+ ))} +
+ )} + + {waitingRecipients.length > 0 && ( +
+

Waiting

+ {waitingRecipients.map((recipient: Recipient) => ( +
+ + {recipient.email} +
+ ))} +
+ )} + + {uncompletedRecipients.length > 0 && ( +
+

Uncompleted

+ {uncompletedRecipients.map((recipient: Recipient) => ( +
+ + {recipient.email} +
+ ))} +
+ )} +
+
+
+
+ ); +}; diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx new file mode 100644 index 000000000..97af9dc9e --- /dev/null +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import { initials } from '@documenso/lib/client-only/recipient-initials'; +import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { Recipient } from '@documenso/prisma/client'; + +import { StackAvatar } from './stack-avatar'; + +export function StackAvatars({ recipients }: { recipients: Recipient[] }) { + const renderStackAvatars = (recipients: Recipient[]) => { + const zIndex = 50; + const itemsToRender = recipients.slice(0, 5); + const remainingItems = recipients.length - itemsToRender.length; + + return itemsToRender.map((recipient: Recipient, index: number) => { + const first = index === 0 ? true : false; + + const lastItemText = + index === itemsToRender.length - 1 && remainingItems > 0 + ? `+${remainingItems + 1}` + : undefined; + + return ( + + ); + }); + }; + + return <>{renderStackAvatars(recipients)}; +} diff --git a/apps/web/src/components/(dashboard)/document-dropzone/document-dropzone.tsx b/apps/web/src/components/(dashboard)/document-dropzone/document-dropzone.tsx index c3c53d32e..0cc8dd22a 100644 --- a/apps/web/src/components/(dashboard)/document-dropzone/document-dropzone.tsx +++ b/apps/web/src/components/(dashboard)/document-dropzone/document-dropzone.tsx @@ -2,7 +2,6 @@ import { Variants, motion } from 'framer-motion'; import { Plus } from 'lucide-react'; -import { useTheme } from 'next-themes'; import { useDropzone } from 'react-dropzone'; import { cn } from '@documenso/ui/lib/utils'; @@ -92,8 +91,6 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo }, }); - const { theme } = useTheme(); - return ( - + {/* */}
@@ -127,7 +123,7 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
diff --git a/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx b/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx index c9c36cc47..f59d42096 100644 --- a/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx +++ b/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx @@ -1,6 +1,4 @@ -import React from 'react'; - -import { LucideIcon } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import { cn } from '@documenso/ui/lib/utils'; @@ -13,7 +11,12 @@ export type CardMetricProps = { export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricProps) => { return ( -
+
{Icon && } diff --git a/apps/web/src/components/(dashboard)/pdf-viewer/pdf-viewer.tsx b/apps/web/src/components/(dashboard)/pdf-viewer/pdf-viewer.tsx index f35e5d48a..77284a02b 100644 --- a/apps/web/src/components/(dashboard)/pdf-viewer/pdf-viewer.tsx +++ b/apps/web/src/components/(dashboard)/pdf-viewer/pdf-viewer.tsx @@ -67,16 +67,6 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie const pageX = event.clientX - left; const pageY = event.clientY - top; - console.log({ - pageNumber, - numPages, - originalEvent: event, - pageHeight: height, - pageWidth: width, - pageX, - pageY, - }); - if (onPageClick) { onPageClick({ pageNumber, @@ -120,10 +110,10 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie onLoadSuccess={(d) => onDocumentLoaded(d)} externalLinkTarget="_blank" loading={ -
- +
+ -

Loading document...

+

Loading document...

} > @@ -137,6 +127,8 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie onDocumentPageClick(e, i + 1)} />
diff --git a/apps/web/src/components/(dashboard)/pdf-viewer/types.ts b/apps/web/src/components/(dashboard)/pdf-viewer/types.ts new file mode 100644 index 000000000..54c9c5d5a --- /dev/null +++ b/apps/web/src/components/(dashboard)/pdf-viewer/types.ts @@ -0,0 +1,2 @@ +export const PDF_VIEWER_CONTAINER_SELECTOR = '.react-pdf__Document'; +export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page'; diff --git a/apps/web/src/components/(marketing)/callout.tsx b/apps/web/src/components/(marketing)/callout.tsx index 30a1abdbf..0c2613ec8 100644 --- a/apps/web/src/components/(marketing)/callout.tsx +++ b/apps/web/src/components/(marketing)/callout.tsx @@ -41,16 +41,16 @@ export const Callout = () => { - event('view-github')} - > - - + +
); }; diff --git a/apps/web/src/components/(marketing)/hero.tsx b/apps/web/src/components/(marketing)/hero.tsx index b406b51cc..7f7aa6d05 100644 --- a/apps/web/src/components/(marketing)/hero.tsx +++ b/apps/web/src/components/(marketing)/hero.tsx @@ -114,12 +114,19 @@ export const Hero = ({ className, ...props }: HeroProps) => { - event('view-github')}> - - + + { For small teams and individuals who need a simple solution

- event('view-github')} - > - - +

Host your own instance

diff --git a/apps/web/src/components/form/form-error-message.tsx b/apps/web/src/components/form/form-error-message.tsx index 059a2eb83..6fa7c32b0 100644 --- a/apps/web/src/components/form/form-error-message.tsx +++ b/apps/web/src/components/form/form-error-message.tsx @@ -1,11 +1,10 @@ import { AnimatePresence, motion } from 'framer-motion'; -import { FieldError } from 'react-hook-form'; import { cn } from '@documenso/ui/lib/utils'; export type FormErrorMessageProps = { className?: string; - error: FieldError | undefined; + error: { message?: string } | undefined; }; export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => { diff --git a/apps/web/src/components/formatter/document-status.tsx b/apps/web/src/components/formatter/document-status.tsx index 54feb3bd5..fbe1e1a3b 100644 --- a/apps/web/src/components/formatter/document-status.tsx +++ b/apps/web/src/components/formatter/document-status.tsx @@ -1,6 +1,7 @@ import { HTMLAttributes } from 'react'; -import { CheckCircle2, Clock, File, LucideIcon } from 'lucide-react'; +import { CheckCircle2, Clock, File } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; @@ -31,14 +32,24 @@ const FRIENDLY_STATUS_MAP: Record = { export type DocumentStatusProps = HTMLAttributes & { status: InternalDocumentStatus; + inheritColor?: boolean; }; -export const DocumentStatus = ({ className, status, ...props }: DocumentStatusProps) => { +export const DocumentStatus = ({ + className, + status, + inheritColor, + ...props +}: DocumentStatusProps) => { const { label, icon: Icon, color } = FRIENDLY_STATUS_MAP[status]; return ( - + {label} ); diff --git a/apps/web/src/components/forms/edit-document.tsx b/apps/web/src/components/forms/edit-document.tsx deleted file mode 100644 index 2529f01de..000000000 --- a/apps/web/src/components/forms/edit-document.tsx +++ /dev/null @@ -1,144 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -import dynamic from 'next/dynamic'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { Loader } from 'lucide-react'; -import { useTheme } from 'next-themes'; -import { useForm } from 'react-hook-form'; - -import { Document, User } from '@documenso/prisma/client'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; - -import { AddFieldsFormPartial } from './edit-document/add-fields'; -import { AddSignersFormPartial } from './edit-document/add-signers'; -import { TEditDocumentFormSchema, ZEditDocumentFormSchema } from './edit-document/types'; - -const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), { - ssr: false, - loading: () => ( -
- - -

Loading document...

-
- ), -}); - -const MAX_STEP = 2; - -export type EditDocumentFormProps = { - className?: string; - user: User; - document: Document; -}; - -export const EditDocumentForm = ({ className, document, user: _user }: EditDocumentFormProps) => { - const documentUrl = `data:application/pdf;base64,${document.document}`; - - const [step, setStep] = useState(0); - - const { - control, - // handleSubmit, - watch, - formState: { errors, isSubmitting, isValid }, - } = useForm({ - mode: 'onBlur', - defaultValues: { - signers: [ - { - name: '', - email: '', - }, - ], - }, - resolver: zodResolver(ZEditDocumentFormSchema), - }); - - const { theme } = useTheme(); - - const canGoBack = step > 0; - const canGoNext = isValid && step < MAX_STEP; - - const onGoBackClick = () => setStep((prev) => Math.max(0, prev - 1)); - const onGoNextClick = () => setStep((prev) => Math.min(MAX_STEP, prev + 1)); - - return ( -
- - - - - - -
-
- {step === 0 && ( - - )} - - {step === 1 && ( - - )} - -
-

- Add Signers ({step + 1}/{MAX_STEP + 1}) -

- -
-
-
- -
- - - -
-
-
-
-
- ); -}; diff --git a/apps/web/src/components/forms/edit-document/add-fields.action.ts b/apps/web/src/components/forms/edit-document/add-fields.action.ts new file mode 100644 index 000000000..022c593de --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-fields.action.ts @@ -0,0 +1,31 @@ +'use server'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; + +import { TAddFieldsFormSchema } from './add-fields.types'; + +export type AddFieldsActionInput = TAddFieldsFormSchema & { + documentId: number; +}; + +export const addFields = async ({ documentId, fields }: AddFieldsActionInput) => { + 'use server'; + + const { id: userId } = await getRequiredServerComponentSession(); + + await setFieldsForDocument({ + userId, + documentId, + fields: fields.map((field) => ({ + id: field.nativeId, + signerEmail: field.signerEmail, + type: field.type, + pageNumber: field.pageNumber, + pageX: field.pageX, + pageY: field.pageY, + pageWidth: field.pageWidth, + pageHeight: field.pageHeight, + })), + }); +}; diff --git a/apps/web/src/components/forms/edit-document/add-fields.tsx b/apps/web/src/components/forms/edit-document/add-fields.tsx index edad9de68..889cdbf29 100644 --- a/apps/web/src/components/forms/edit-document/add-fields.tsx +++ b/apps/web/src/components/forms/edit-document/add-fields.tsx @@ -1,12 +1,15 @@ 'use client'; -import { useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Caveat } from 'next/font/google'; +import { useRouter } from 'next/navigation'; -import { Check, ChevronsUpDown } from 'lucide-react'; -import { Control, FieldErrors, UseFormWatch } from 'react-hook-form'; +import { Check, ChevronsUpDown, Info } from 'lucide-react'; +import { nanoid } from 'nanoid'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { Document, Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -18,8 +21,23 @@ import { CommandItem, } from '@documenso/ui/primitives/command'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import { useToast } from '@documenso/ui/primitives/use-toast'; -import { TEditDocumentFormSchema } from './types'; +import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types'; +import { getBoundingClientRect } from '~/helpers/getBoundingClientRect'; + +import { addFields } from './add-fields.action'; +import { TAddFieldsFormSchema } from './add-fields.types'; +import { + EditDocumentFormContainer, + EditDocumentFormContainerActions, + EditDocumentFormContainerContent, + EditDocumentFormContainerFooter, + EditDocumentFormContainerStep, +} from './container'; +import { FieldItem } from './field-item'; +import { FRIENDLY_FIELD_TYPE } from './types'; const fontCaveat = Caveat({ weight: ['500'], @@ -28,164 +46,532 @@ const fontCaveat = Caveat({ variable: '--font-caveat', }); +const DEFAULT_HEIGHT_PERCENT = 5; +const DEFAULT_WIDTH_PERCENT = 15; + +const MIN_HEIGHT_PX = 60; +const MIN_WIDTH_PX = 200; + export type AddFieldsFormProps = { - className?: string; - control: Control; - watch: UseFormWatch; - errors: FieldErrors; - isSubmitting: boolean; - theme: string; + recipients: Recipient[]; + fields: Field[]; + document: Document; + onContinue?: () => void; + onGoBack?: () => void; }; export const AddFieldsFormPartial = ({ - className, - control: _control, - watch, - errors: _errors, - isSubmitting: _isSubmitting, - theme, + recipients, + fields, + document, + onContinue, + onGoBack, }: AddFieldsFormProps) => { - const signers = watch('signers'); + const { toast } = useToast(); + const router = useRouter(); - const [selectedSigner, setSelectedSigner] = useState(() => signers[0]); + const { + control, + handleSubmit, + formState: { isSubmitting }, + } = useForm({ + defaultValues: { + fields: fields.map((field) => ({ + nativeId: field.id, + formId: `${field.id}-${field.documentId}`, + pageNumber: field.page, + type: field.type, + pageX: Number(field.positionX), + pageY: Number(field.positionY), + pageWidth: Number(field.width), + pageHeight: Number(field.height), + signerEmail: + recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '', + })), + }, + }); + + const { + append, + remove, + update, + fields: localFields, + } = useFieldArray({ + control, + name: 'fields', + }); + + const [selectedField, setSelectedField] = useState(null); + const [selectedSigner, setSelectedSigner] = useState(null); + + const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT; + + const [visible, setVisible] = useState(false); + const [coords, setCoords] = useState({ + x: 0, + y: 0, + }); + + const fieldBounds = useRef({ + height: 0, + width: 0, + }); + + /** + * Given a mouse event, find the nearest pdf page element. + */ + const getPage = (event: MouseEvent) => { + if (!(event.target instanceof HTMLElement)) { + return null; + } + + const target = event.target; + + const $page = + target.closest(PDF_VIEWER_PAGE_SELECTOR) ?? + target.querySelector(PDF_VIEWER_PAGE_SELECTOR); + + if (!$page) { + return null; + } + + return $page; + }; + + /** + * Provided a page and a field, calculate the position of the field + * as a percentage of the page width and height. + */ + const getFieldPosition = (page: HTMLElement, field: HTMLElement) => { + const { + top: pageTop, + left: pageLeft, + height: pageHeight, + width: pageWidth, + } = getBoundingClientRect(page); + + const { + top: fieldTop, + left: fieldLeft, + height: fieldHeight, + width: fieldWidth, + } = getBoundingClientRect(field); + + return { + x: ((fieldLeft - pageLeft) / pageWidth) * 100, + y: ((fieldTop - pageTop) / pageHeight) * 100, + width: (fieldWidth / pageWidth) * 100, + height: (fieldHeight / pageHeight) * 100, + }; + }; + + /** + * Given a mouse event, determine if the mouse is within the bounds of the + * nearest pdf page element. + */ + const isWithinPageBounds = useCallback((event: MouseEvent) => { + const $page = getPage(event); + + if (!$page) { + return false; + } + + const { top, left, height, width } = $page.getBoundingClientRect(); + + if (event.clientY > top + height || event.clientY < top) { + return false; + } + + if (event.clientX > left + width || event.clientX < left) { + return false; + } + + return true; + }, []); + + const onMouseMove = useCallback( + (event: MouseEvent) => { + if (!isWithinPageBounds(event)) { + setVisible(false); + return; + } + + setVisible(true); + setCoords({ + x: event.clientX - fieldBounds.current.width / 2, + y: event.clientY - fieldBounds.current.height / 2, + }); + }, + [isWithinPageBounds], + ); + + const onMouseClick = useCallback( + (event: MouseEvent) => { + if (!selectedField || !selectedSigner) { + return; + } + + const $page = getPage(event); + + if (!$page || !isWithinPageBounds(event)) { + return; + } + + const { top, left, height, width } = getBoundingClientRect($page); + + const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10); + + // Calculate x and y as a percentage of the page width and height + let pageX = ((event.pageX - left) / width) * 100; + let pageY = ((event.pageY - top) / height) * 100; + + // Get the bounds as a percentage of the page width and height + const fieldPageWidth = (fieldBounds.current.width / width) * 100; + const fieldPageHeight = (fieldBounds.current.height / height) * 100; + + // And center it based on the bounds + pageX -= fieldPageWidth / 2; + pageY -= fieldPageHeight / 2; + + append({ + formId: nanoid(12), + type: selectedField, + pageNumber, + pageX, + pageY, + pageWidth: fieldPageWidth, + pageHeight: fieldPageHeight, + signerEmail: selectedSigner.email, + }); + + setVisible(false); + setSelectedField(null); + }, + [append, isWithinPageBounds, selectedField, selectedSigner], + ); + + const onFieldResize = useCallback( + (node: HTMLElement, index: number) => { + const field = localFields[index]; + + const $page = window.document.querySelector( + `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, + ); + + if (!$page) { + return; + } + + const { + x: pageX, + y: pageY, + width: pageWidth, + height: pageHeight, + } = getFieldPosition($page, node); + + update(index, { + ...field, + pageX, + pageY, + pageWidth, + pageHeight, + }); + }, + [localFields, update], + ); + + const onFieldMove = useCallback( + (node: HTMLElement, index: number) => { + const field = localFields[index]; + + const $page = window.document.querySelector( + `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, + ); + + if (!$page) { + return; + } + + const { x: pageX, y: pageY } = getFieldPosition($page, node); + + update(index, { + ...field, + pageX, + pageY, + }); + }, + [localFields, update], + ); + + const onFormSubmit = handleSubmit(async (data: TAddFieldsFormSchema) => { + try { + // Custom invocation server action + await addFields({ + documentId: document.id, + fields: data.fields, + }); + + router.refresh(); + + onContinue?.(); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while adding signers.', + variant: 'destructive', + }); + } + }); + + useEffect(() => { + if (selectedField) { + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('click', onMouseClick); + } + + return () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('click', onMouseClick); + }; + }, [onMouseClick, onMouseMove, selectedField]); + + useEffect(() => { + const $page = window.document.querySelector(PDF_VIEWER_PAGE_SELECTOR); + + if (!$page) { + return; + } + + const { height, width } = $page.getBoundingClientRect(); + + fieldBounds.current = { + height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX), + width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX), + }; + }, []); + + useEffect(() => { + setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]); + }, [recipients]); return ( -
-

Edit Document

- -

- Add all relevant fields for each recipient. -

- -
- - - - - - - - - - - - - {signers.map((signer, index) => ( - setSelectedSigner(signer)}> - - {signer.name && ( - - {signer.name} ({signer.email}) - - )} - - {!signer.name && {signer.email}} - - ))} - - - - - -
-
- + )} - + + + + + + - + + {recipients.map((recipient, index) => ( + setSelectedSigner(recipient)} + > + {recipient.sendStatus !== SendStatus.SENT ? ( + + ) : ( + + + + + + This document has already been sent to this recipient. You can no longer + edit this recipient. + + + )} + + {recipient.name && ( + + {recipient.name} ({recipient.email}) + + )} + + {!recipient.name && ( + + {recipient.email} + + )} + + ))} + + + + + +
+
+ + + + + + + +
+
-
-
+ + + + + + onFormSubmit()} + onGoBackClick={onGoBack} + /> + + ); }; diff --git a/apps/web/src/components/forms/edit-document/add-fields.types.ts b/apps/web/src/components/forms/edit-document/add-fields.types.ts new file mode 100644 index 000000000..e7a509632 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-fields.types.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { FieldType } from '@documenso/prisma/client'; + +export const ZAddFieldsFormSchema = z.object({ + fields: z.array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + type: z.nativeEnum(FieldType), + signerEmail: z.string().min(1), + pageNumber: z.number().min(1), + pageX: z.number().min(0), + pageY: z.number().min(0), + pageWidth: z.number().min(0), + pageHeight: z.number().min(0), + }), + ), +}); + +export type TAddFieldsFormSchema = z.infer; diff --git a/apps/web/src/components/forms/edit-document/add-signers.action.ts b/apps/web/src/components/forms/edit-document/add-signers.action.ts new file mode 100644 index 000000000..a37c12af8 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-signers.action.ts @@ -0,0 +1,26 @@ +'use server'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; + +import { TAddSignersFormSchema } from './add-signers.types'; + +export type AddSignersActionInput = TAddSignersFormSchema & { + documentId: number; +}; + +export const addSigners = async ({ documentId, signers }: AddSignersActionInput) => { + 'use server'; + + const { id: userId } = await getRequiredServerComponentSession(); + + await setRecipientsForDocument({ + userId, + documentId, + recipients: signers.map((signer) => ({ + id: signer.nativeId, + email: signer.email, + name: signer.name, + })), + }); +}; diff --git a/apps/web/src/components/forms/edit-document/add-signers.tsx b/apps/web/src/components/forms/edit-document/add-signers.tsx index 9292d6e3a..08cdeb4cb 100644 --- a/apps/web/src/components/forms/edit-document/add-signers.tsx +++ b/apps/web/src/components/forms/edit-document/add-signers.tsx @@ -1,67 +1,177 @@ 'use client'; +import React, { useId } from 'react'; + +import { useRouter } from 'next/navigation'; + import { AnimatePresence, motion } from 'framer-motion'; import { Plus, Trash } from 'lucide-react'; -import { Control, Controller, FieldErrors, useFieldArray } from 'react-hook-form'; +import { nanoid } from 'nanoid'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; -import { cn } from '@documenso/ui/lib/utils'; +import { Document, Field, Recipient, SendStatus } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { FormErrorMessage } from '~/components/form/form-error-message'; -import { TEditDocumentFormSchema } from './types'; +import { addSigners } from './add-signers.action'; +import { TAddSignersFormSchema } from './add-signers.types'; +import { + EditDocumentFormContainer, + EditDocumentFormContainerActions, + EditDocumentFormContainerContent, + EditDocumentFormContainerFooter, + EditDocumentFormContainerStep, +} from './container'; export type AddSignersFormProps = { - className?: string; - control: Control; - errors: FieldErrors; - isSubmitting: boolean; + recipients: Recipient[]; + fields: Field[]; + document: Document; + onContinue?: () => void; + onGoBack?: () => void; }; export const AddSignersFormPartial = ({ - className, - control, - errors, - isSubmitting, + recipients, + fields: _fields, + document: document, + onContinue, + onGoBack, }: AddSignersFormProps) => { + const { toast } = useToast(); + const router = useRouter(); + + const initialId = useId(); + const { - append, + control, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + signers: + recipients.length > 0 + ? recipients.map((recipient) => ({ + nativeId: recipient.id, + formId: String(recipient.id), + name: recipient.name, + email: recipient.email, + })) + : [ + { + formId: initialId, + name: '', + email: '', + }, + ], + }, + }); + + const { + append: appendSigner, fields: signers, - remove, + remove: removeSigner, } = useFieldArray({ control, name: 'signers', }); + const hasBeenSentToRecipientId = (id?: number) => { + if (!id) { + return false; + } + + return recipients.some( + (recipient) => recipient.id === id && recipient.sendStatus === SendStatus.SENT, + ); + }; + + const onAddSigner = () => { + appendSigner({ + formId: nanoid(12), + name: '', + email: '', + }); + }; + + const onRemoveSigner = (index: number) => { + const signer = signers[index]; + + if (hasBeenSentToRecipientId(signer.nativeId)) { + toast({ + title: 'Cannot remove signer', + description: 'This signer has already received the document.', + variant: 'destructive', + }); + + return; + } + + removeSigner(index); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { + onAddSigner(); + } + }; + + const onFormSubmit = handleSubmit(async (data: TAddSignersFormSchema) => { + try { + // Custom invocation server action + await addSigners({ + documentId: document.id, + signers: data.signers, + }); + + router.refresh(); + + onContinue?.(); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while adding signers.', + variant: 'destructive', + }); + } + }); + return ( -
-

Add Signers

- -

- Add the people who will sign the document. -

- -
- -
-
+ + +
- {signers.map((field, index) => ( - + {signers.map((signer, index) => ( +
- + ( )} @@ -69,17 +179,18 @@ export const AddSignersFormPartial = ({
- + ( )} @@ -89,9 +200,13 @@ export const AddSignersFormPartial = ({
@@ -106,22 +221,26 @@ export const AddSignersFormPartial = ({
+ +
-
-
-
+
+ + + + + onFormSubmit()} + onGoBackClick={onGoBack} + /> + +
); }; diff --git a/apps/web/src/components/forms/edit-document/add-signers.types.ts b/apps/web/src/components/forms/edit-document/add-signers.types.ts new file mode 100644 index 000000000..2b1418934 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-signers.types.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const ZAddSignersFormSchema = z.object({ + signers: z + .array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + email: z.string().min(1).email(), + name: z.string(), + }), + ) + .refine((signers) => { + const emails = signers.map((signer) => signer.email); + return new Set(emails).size === emails.length; + }, 'Signers must have unique emails'), +}); + +export type TAddSignersFormSchema = z.infer; diff --git a/apps/web/src/components/forms/edit-document/add-subject.action.ts b/apps/web/src/components/forms/edit-document/add-subject.action.ts new file mode 100644 index 000000000..3beb166ca --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-subject.action.ts @@ -0,0 +1,21 @@ +'use server'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { sendDocument } from '@documenso/lib/server-only/document/send-document'; + +import { TAddSubjectFormSchema } from './add-subject.types'; + +export type CompleteDocumentActionInput = TAddSubjectFormSchema & { + documentId: number; +}; + +export const completeDocument = async ({ documentId }: CompleteDocumentActionInput) => { + 'use server'; + + const { id: userId } = await getRequiredServerComponentSession(); + + await sendDocument({ + userId, + documentId, + }); +}; diff --git a/apps/web/src/components/forms/edit-document/add-subject.tsx b/apps/web/src/components/forms/edit-document/add-subject.tsx new file mode 100644 index 000000000..c5c2bdd83 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-subject.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { useForm } from 'react-hook-form'; + +import { Document, DocumentStatus, Field, Recipient } from '@documenso/prisma/client'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { FormErrorMessage } from '~/components/form/form-error-message'; + +import { completeDocument } from './add-subject.action'; +import { TAddSubjectFormSchema } from './add-subject.types'; +import { + EditDocumentFormContainer, + EditDocumentFormContainerActions, + EditDocumentFormContainerContent, + EditDocumentFormContainerFooter, + EditDocumentFormContainerStep, +} from './container'; + +export type AddSubjectFormProps = { + recipients: Recipient[]; + fields: Field[]; + document: Document; + onContinue?: () => void; + onGoBack?: () => void; +}; + +export const AddSubjectFormPartial = ({ + recipients: _recipients, + fields: _fields, + document, + onContinue, + onGoBack, +}: AddSubjectFormProps) => { + const { toast } = useToast(); + const router = useRouter(); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + email: { + subject: '', + message: '', + }, + }, + }); + + const onFormSubmit = handleSubmit(async (data: TAddSubjectFormSchema) => { + const { subject, message } = data.email; + + try { + await completeDocument({ + documentId: document.id, + email: { + subject, + message, + }, + }); + + router.refresh(); + + onContinue?.(); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while sending the document.', + variant: 'destructive', + }); + } + }); + + return ( + + +
+
+
+ + + + + +
+ +
+ + +