diff --git a/.env.example b/.env.example index 80c11413c..15b0b3f5c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,4 @@ # [[AUTH]] -NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="secret" # [[CRYPTO]] @@ -19,14 +18,10 @@ NEXT_PRIVATE_OIDC_WELL_KNOWN="" NEXT_PRIVATE_OIDC_CLIENT_ID="" NEXT_PRIVATE_OIDC_CLIENT_SECRET="" NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC" -# This can be used to still allow signups for OIDC connections -# when signup is disabled via `NEXT_PUBLIC_DISABLE_SIGNUP` -NEXT_PRIVATE_OIDC_ALLOW_SIGNUP="" NEXT_PRIVATE_OIDC_SKIP_VERIFY="" # [[URLS]] NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" -NEXT_PUBLIC_MARKETING_URL="http://localhost:3001" # URL used by the web app to request itself (e.g. local background jobs) NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" @@ -113,13 +108,9 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5 # [[STRIPE]] NEXT_PRIVATE_STRIPE_API_KEY= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= -NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= -NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID= # [[BACKGROUND JOBS]] NEXT_PRIVATE_JOBS_PROVIDER="local" -NEXT_PRIVATE_TRIGGER_API_KEY= -NEXT_PRIVATE_TRIGGER_API_URL= NEXT_PRIVATE_INNGEST_EVENT_KEY= # [[FEATURES]] @@ -135,10 +126,5 @@ E2E_TEST_AUTHENTICATE_USERNAME="Test User" E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com" E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123" -# This is only required for the marketing site -# [[REDIS]] -NEXT_PRIVATE_REDIS_URL= -NEXT_PRIVATE_REDIS_TOKEN= - # [[LOGGER]] NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY= diff --git a/.eslintrc.cjs b/.eslintrc.cjs index ed6ecc0ac..455860ea1 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -5,6 +5,7 @@ module.exports = { rules: { '@next/next/no-img-element': 'off', 'no-unreachable': 'error', + 'react-hooks/exhaustive-deps': 'off', }, settings: { next: { diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index 878eb27d2..af89fedef 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -3,7 +3,7 @@ description: 'Cache or restore if necessary' inputs: node_version: required: false - default: v20.x + default: v22.x runs: using: 'composite' steps: diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml index 59b542fc8..f86dd5e42 100644 --- a/.github/actions/node-install/action.yml +++ b/.github/actions/node-install/action.yml @@ -2,7 +2,7 @@ name: 'Setup node and cache node_modules' inputs: node_version: required: false - default: v20.x + default: v22.x runs: using: 'composite' diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 4673dfca1..9d935d83f 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1,7 +1,7 @@ name: Playwright Tests on: push: - branches: ['main'] + branches: ['main', 'feat/rr7'] pull_request: branches: ['main'] jobs: diff --git a/.gitpod.yml b/.gitpod.yml index de6c917f1..261f8c96b 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -4,9 +4,7 @@ tasks: npm run dx:up && cp .env.example .env && set -a; source .env && - export NEXTAUTH_URL="$(gp url 3000)" && export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" && - export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)" command: npm run d ports: diff --git a/.husky/pre-commit b/.husky/pre-commit index 17f372598..52007e38b 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -4,12 +4,9 @@ SCRIPT_DIR="$(readlink -f "$(dirname "$0")")" MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")" -echo "Copying pdf.js" -npm run copy:pdfjs --workspace apps/** - echo "Copying .well-known/ contents" node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs" -git add "$MONOREPO_ROOT/apps/web/public/" +git add "$MONOREPO_ROOT/apps/remix/public/" npx lint-staged diff --git a/README.md b/README.md index 5239c79d0..d7f79adda 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,3 @@ -> 🚨 We are live on Product Hunt 🎉 Check out our latest launch: The Platform Plan! - -Documenso Platform Plan - Whitelabeled signing flows in your product | Product Hunt - Documenso Logo

@@ -73,9 +69,9 @@ Contact us if you are interested in our Enterprise plan for large organizations Book us with Cal.com ## Tech Stack +

TypeScript - NextJS Made with Prisma Tailwind CSS @@ -85,20 +81,17 @@ Contact us if you are interested in our Enterprise plan for large organizations

- - [Typescript](https://www.typescriptlang.org/) - Language -- [Next.js](https://nextjs.org/) - Framework -- [Prisma](https://www.prisma.io/) - ORM +- [ReactRouter](https://reactrouter.com/) - Framework +- [Prisma](https://www.prisma.io/) - ORM - [Tailwind](https://tailwindcss.com/) - CSS - [shadcn/ui](https://ui.shadcn.com/) - Component Library -- [NextAuth.js](https://next-auth.js.org/) - Authentication - [react-email](https://react.email/) - Email Templates - [tRPC](https://trpc.io/) - API - [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures (launching soon) - [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs - [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation - [Stripe](https://stripe.com/) - Payments -- [Vercel](https://vercel.com) - Hosting @@ -108,7 +101,7 @@ Contact us if you are interested in our Enterprise plan for large organizations To run Documenso locally, you will need -- Node.js (v18 or above) +- Node.js (v22 or above) - Postgres SQL Database - Docker (optional) @@ -171,10 +164,8 @@ git clone https://github.com//documenso 4. Set the following environment variables: - - NEXTAUTH_URL - NEXTAUTH_SECRET - NEXT_PUBLIC_WEBAPP_URL - - NEXT_PUBLIC_MARKETING_URL - NEXT_PRIVATE_DATABASE_URL - NEXT_PRIVATE_DIRECT_DATABASE_URL - NEXT_PRIVATE_SMTP_FROM_NAME @@ -243,16 +234,14 @@ cp .env.example .env The following environment variables must be set: -- `NEXTAUTH_URL` - `NEXTAUTH_SECRET` - `NEXT_PUBLIC_WEBAPP_URL` -- `NEXT_PUBLIC_MARKETING_URL` - `NEXT_PRIVATE_DATABASE_URL` - `NEXT_PRIVATE_DIRECT_DATABASE_URL` - `NEXT_PRIVATE_SMTP_FROM_NAME` - `NEXT_PRIVATE_SMTP_FROM_ADDRESS` -> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for both `NEXTAUTH_URL` and `NEXT_PUBLIC_WEBAPP_URL` variables! +> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for the `NEXT_PUBLIC_WEBAPP_URL` variable! Now you can install the dependencies and build it: diff --git a/packages/ui/components/call-to-action.tsx b/apps/documentation/components/call-to-action.tsx similarity index 91% rename from packages/ui/components/call-to-action.tsx rename to apps/documentation/components/call-to-action.tsx index 1e741e37e..132e172af 100644 --- a/packages/ui/components/call-to-action.tsx +++ b/apps/documentation/components/call-to-action.tsx @@ -1,7 +1,7 @@ import Link from 'next/link'; -import { Button } from '../primitives/button'; -import { Card, CardContent } from '../primitives/card'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; type CallToActionProps = { className?: string; diff --git a/apps/documentation/package.json b/apps/documentation/package.json index fc06f6547..76def45e1 100644 --- a/apps/documentation/package.json +++ b/apps/documentation/package.json @@ -7,8 +7,7 @@ "build": "next build", "start": "next start -p 3002", "lint:fix": "next lint --fix", - "clean": "rimraf .next && rimraf node_modules", - "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" + "clean": "rimraf .next && rimraf node_modules" }, "dependencies": { "@documenso/assets": "*", diff --git a/apps/documentation/pages/developers/_meta.json b/apps/documentation/pages/developers/_meta.json index a9f3c3823..0057496b3 100644 --- a/apps/documentation/pages/developers/_meta.json +++ b/apps/documentation/pages/developers/_meta.json @@ -14,4 +14,4 @@ "public-api": "Public API", "embedding": "Embedding", "webhooks": "Webhooks" -} \ No newline at end of file +} diff --git a/apps/documentation/pages/developers/embedding/css-variables.mdx b/apps/documentation/pages/developers/embedding/css-variables.mdx index 143967042..d9259545e 100644 --- a/apps/documentation/pages/developers/embedding/css-variables.mdx +++ b/apps/documentation/pages/developers/embedding/css-variables.mdx @@ -111,6 +111,83 @@ The colors will be automatically converted to the appropriate format internally. 4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system. +## CSS Class Targets + +In addition to CSS variables, specific components in the embedded experience can be targeted using CSS classes for more granular styling: + +### Component Classes + +| Class Name | Description | +| --------------------------------- | ----------------------------------------------------------------------- | +| `.embed--Root` | Main container for the embedded signing experience | +| `.embed--DocumentContainer` | Container for the document and signing widget | +| `.embed--DocumentViewer` | Container for the document viewer | +| `.embed--DocumentWidget` | The signing widget container | +| `.embed--DocumentWidgetContainer` | Outer container for the signing widget, handles positioning | +| `.embed--DocumentWidgetHeader` | Header section of the signing widget | +| `.embed--DocumentWidgetContent` | Main content area of the signing widget | +| `.embed--DocumentWidgetForm` | Form section within the signing widget | +| `.embed--DocumentWidgetFooter` | Footer section of the signing widget | +| `.embed--WaitingForTurn` | Container for the waiting screen when it's not the user's turn to sign | +| `.embed--DocumentCompleted` | Container for the completion screen after signing | +| `.field--FieldRootContainer` | Base container for document fields (signatures, text, checkboxes, etc.) | + +Field components also expose several data attributes that can be used for styling different states: + +| Data Attribute | Values | Description | +| ------------------- | ---------------------------------------------- | ------------------------------------ | +| `[data-field-type]` | `SIGNATURE`, `TEXT`, `CHECKBOX`, `RADIO`, etc. | The type of field | +| `[data-inserted]` | `true`, `false` | Whether the field has been filled | +| `[data-validate]` | `true`, `false` | Whether the field is being validated | + +### Field Styling Example + +```css +/* Style all field containers */ +.field--FieldRootContainer { + transition: all 200ms ease; +} + +/* Style specific field types */ +.field--FieldRootContainer[data-field-type='SIGNATURE'] { + background-color: rgba(0, 0, 0, 0.02); +} + +/* Style inserted fields */ +.field--FieldRootContainer[data-inserted='true'] { + background-color: var(--primary); + opacity: 0.2; +} + +/* Style fields being validated */ +.field--FieldRootContainer[data-validate='true'] { + border-color: orange; +} +``` + +### Example Usage + +```css +/* Custom styles for the document widget */ +.embed--DocumentWidget { + background-color: #ffffff; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); +} + +/* Custom styles for the waiting screen */ +.embed--WaitingForTurn { + background-color: #f9fafb; + padding: 2rem; +} + +/* Responsive adjustments for the document container */ +@media (min-width: 768px) { + .embed--DocumentContainer { + gap: 2rem; + } +} +``` + ## Related - [React Integration](/developers/embedding/react) diff --git a/apps/documentation/pages/developers/embedding/index.mdx b/apps/documentation/pages/developers/embedding/index.mdx index 27d6f6f8f..3a5535f89 100644 --- a/apps/documentation/pages/developers/embedding/index.mdx +++ b/apps/documentation/pages/developers/embedding/index.mdx @@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe ``` diff --git a/apps/documentation/pages/developers/embedding/preact.mdx b/apps/documentation/pages/developers/embedding/preact.mdx index 91176723f..3f6f579f5 100644 --- a/apps/documentation/pages/developers/embedding/preact.mdx +++ b/apps/documentation/pages/developers/embedding/preact.mdx @@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => { } `; const cssVars = { - colorPrimary: '#0000FF', - colorBackground: '#F5F5F5', - borderRadius: '8px', + primary: '#0000FF', + background: '#F5F5F5', + radius: '8px', }; return ( diff --git a/apps/documentation/pages/developers/embedding/react.mdx b/apps/documentation/pages/developers/embedding/react.mdx index 05dc3a8aa..2c259ddb8 100644 --- a/apps/documentation/pages/developers/embedding/react.mdx +++ b/apps/documentation/pages/developers/embedding/react.mdx @@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => { `} // CSS Variables cssVars={{ - colorPrimary: '#0000FF', - colorBackground: '#F5F5F5', - borderRadius: '8px', + primary: '#0000FF', + background: '#F5F5F5', + radius: '8px', }} // Dark Mode Control darkModeDisabled={true} diff --git a/apps/documentation/pages/developers/embedding/solid.mdx b/apps/documentation/pages/developers/embedding/solid.mdx index e19007a43..135e93ff6 100644 --- a/apps/documentation/pages/developers/embedding/solid.mdx +++ b/apps/documentation/pages/developers/embedding/solid.mdx @@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => { } `; const cssVars = { - colorPrimary: '#0000FF', - colorBackground: '#F5F5F5', - borderRadius: '8px', + primary: '#0000FF', + background: '#F5F5F5', + radius: '8px', }; return ( diff --git a/apps/documentation/pages/developers/embedding/svelte.mdx b/apps/documentation/pages/developers/embedding/svelte.mdx index 46ec69c63..126b43e10 100644 --- a/apps/documentation/pages/developers/embedding/svelte.mdx +++ b/apps/documentation/pages/developers/embedding/svelte.mdx @@ -97,9 +97,9 @@ Platform customers have access to advanced styling options: } `; const cssVars = { - colorPrimary: '#0000FF', - colorBackground: '#F5F5F5', - borderRadius: '8px', + primary: '#0000FF', + background: '#F5F5F5', + radius: '8px', }; diff --git a/apps/documentation/pages/developers/embedding/vue.mdx b/apps/documentation/pages/developers/embedding/vue.mdx index 8051dbe35..dc68f979d 100644 --- a/apps/documentation/pages/developers/embedding/vue.mdx +++ b/apps/documentation/pages/developers/embedding/vue.mdx @@ -97,9 +97,9 @@ Platform customers have access to advanced styling options: } `; const cssVars = { - colorPrimary: '#0000FF', - colorBackground: '#F5F5F5', - borderRadius: '8px', + primary: '#0000FF', + background: '#F5F5F5', + radius: '8px', }; diff --git a/apps/documentation/pages/developers/local-development/index.mdx b/apps/documentation/pages/developers/local-development/index.mdx index 148e87a3a..bf9f92723 100644 --- a/apps/documentation/pages/developers/local-development/index.mdx +++ b/apps/documentation/pages/developers/local-development/index.mdx @@ -16,18 +16,16 @@ Pick the one that fits your needs the best. ## Tech Stack - [Typescript](https://www.typescriptlang.org/) - Language -- [Next.js](https://nextjs.org/) - Framework +- [React Router](https://reactrouter.com/) - Framework - [Prisma](https://www.prisma.io/) - ORM - [Tailwind](https://tailwindcss.com/) - CSS - [shadcn/ui](https://ui.shadcn.com/) - Component Library -- [NextAuth.js](https://next-auth.js.org/) - Authentication - [react-email](https://react.email/) - Email Templates - [tRPC](https://trpc.io/) - API - [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures - [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs - [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation - [Stripe](https://stripe.com/) - Payments -- [Vercel](https://vercel.com) - Hosting
diff --git a/apps/documentation/pages/developers/local-development/manual.mdx b/apps/documentation/pages/developers/local-development/manual.mdx index fe56e0cb1..ed98338cf 100644 --- a/apps/documentation/pages/developers/local-development/manual.mdx +++ b/apps/documentation/pages/developers/local-development/manual.mdx @@ -32,10 +32,8 @@ Run `npm i` in the root directory to install the dependencies required for the p Set up the following environment variables in the `.env` file: ```bash -NEXTAUTH_URL NEXTAUTH_SECRET NEXT_PUBLIC_WEBAPP_URL -NEXT_PUBLIC_MARKETING_URL NEXT_PRIVATE_DATABASE_URL NEXT_PRIVATE_DIRECT_DATABASE_URL NEXT_PRIVATE_SMTP_FROM_NAME diff --git a/apps/documentation/pages/developers/local-development/translations.mdx b/apps/documentation/pages/developers/local-development/translations.mdx index a776dc50c..2fbb6eb96 100644 --- a/apps/documentation/pages/developers/local-development/translations.mdx +++ b/apps/documentation/pages/developers/local-development/translations.mdx @@ -13,35 +13,13 @@ Documenso uses the following stack to handle translations: Additional reading can be found in the [Lingui documentation](https://lingui.dev/introduction). -## Requirements - -You **must** insert **`setupI18nSSR()`** when creating any of the following files: - -- Server layout.tsx -- Server page.tsx -- Server loading.tsx - -Server meaning it does not have `'use client'` in it. - -```tsx -import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; - -export default function SomePage() { - setupI18nSSR(); // Required if there are translations within the page, or nested in components. - - // Rest of code... -} -``` - -Additional information can be found [here.](https://lingui.dev/tutorials/react-rsc#pages-layouts-and-lingui) - ## Quick guide If you require more in-depth information, please see the [Lingui documentation](https://lingui.dev/introduction). ### HTML -Wrap all text to translate in **``** tags exported from **@lingui/macro** (not @lingui/react). +Wrap all text to translate in **``** tags exported from **@lingui/react/macro**. ```html

@@ -64,8 +42,9 @@ For text that is broken into elements, but represent a whole sentence, you must ### Constants outside of react components ```tsx -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; // Wrap text in msg`text to translate` when it's in a constant here, or another file/package. export const CONSTANT_WITH_MSG = { @@ -98,31 +77,13 @@ Lingui provides a Plural component to make it easy. See full documentation [here Lingui provides a [DateTime instance](https://lingui.dev/ref/core#i18n.date) with the configured locale. -#### Server components - -Note that the i18n instance is coming from **setupI18nSSR**. - ```tsx import { Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; export const SomeComponent = () => { - const { i18n } = setupI18nSSR(); + const { i18n } = useLingui(); return The current date is {i18n.date(new Date(), { dateStyle: 'short' })}; }; ``` - -#### Client components - -Note that the i18n instance is coming from the **import**. - -```tsx -import { i18n } from '@lingui/core'; -import { Trans } from '@lingui/macro'; -import { useLingui } from '@lingui/react'; - -export const SomeComponent = () => { - return The current date is {i18n.date(new Date(), { dateStyle: 'short' })}; -}; -``` diff --git a/apps/documentation/pages/developers/public-api/index.mdx b/apps/documentation/pages/developers/public-api/index.mdx index 94b728b53..050810863 100644 --- a/apps/documentation/pages/developers/public-api/index.mdx +++ b/apps/documentation/pages/developers/public-api/index.mdx @@ -3,6 +3,8 @@ title: Public API description: Learn how to interact with your documents programmatically using the Documenso public API. --- +import { Callout, Steps } from 'nextra/components'; + # Public API Documenso provides a public REST API enabling you to interact with your documents programmatically. The API exposes various HTTP endpoints that allow you to perform operations such as: @@ -13,10 +15,35 @@ Documenso provides a public REST API enabling you to interact with your document The documentation walks you through creating API keys and using them to authenticate your API requests. You'll also learn about the available endpoints, request and response formats, and how to use the API. -## Swagger Documentation +## API V1 - Stable -The [Swagger documentation](https://app.documenso.com/api/v1/openapi) also provides information about the API endpoints, request parameters, response formats, and authentication methods. +Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods. + +## API V2 - Beta + +API V2 is currently beta, and will be subject to breaking changes + +Check out the [API V2 documentation](https://documen.so/api-v2-docs) for details about the API endpoints, request parameters, response formats, and authentication methods. + +Our new API V2 supports the following typed SDKs: + +- [TypeScript](https://github.com/documenso/sdk-typescript) +- [Python](https://github.com/documenso/sdk-python) +- [Go](https://github.com/documenso/sdk-go) + + + For the staging API, please use the following base URL: + `https://stg-app.documenso.dev/api/v2-beta/` + + +🚀 [V2 Announcement](https://documen.so/sdk-blog) + +📖 [Documentation](https://documen.so/api-v2-docs) + +💬 [Leave Feedback](https://documen.so/sdk-feedback) + +🔔 [Breaking Changes](https://documen.so/sdk-breaking) ## Availability -The API is available to individual users and teams. +The API is available to individual users, teams and higher plans. [Fair Use](https://documen.so/fair) applies. diff --git a/apps/documentation/pages/developers/self-hosting/how-to.mdx b/apps/documentation/pages/developers/self-hosting/how-to.mdx index 0d1583859..4025ce6d0 100644 --- a/apps/documentation/pages/developers/self-hosting/how-to.mdx +++ b/apps/documentation/pages/developers/self-hosting/how-to.mdx @@ -5,7 +5,7 @@ description: Learn how to self-host Documenso on your server or cloud infrastruc import { Callout, Steps } from 'nextra/components'; -import { CallToAction } from '@documenso/ui/components/call-to-action'; +import { CallToAction } from '../../../components/call-to-action'; # Self Hosting @@ -35,10 +35,8 @@ cp .env.example .env Open the `.env` file and fill in the following variables: ```bash -- NEXTAUTH_URL - NEXTAUTH_SECRET - NEXT_PUBLIC_WEBAPP_URL -- NEXT_PUBLIC_MARKETING_URL - NEXT_PRIVATE_DATABASE_URL - NEXT_PRIVATE_DIRECT_DATABASE_URL - NEXT_PRIVATE_SMTP_FROM_NAME @@ -46,8 +44,8 @@ Open the `.env` file and fill in the following variables: ``` - If you use a reverse proxy in front of Documenso, don't forget to provide the public URL for both - the `NEXTAUTH_URL` and `NEXT_PUBLIC_WEBAPP_URL` variables! + If you use a reverse proxy in front of Documenso, don't forget to provide the public URL for the + `NEXT_PUBLIC_WEBAPP_URL` variable! ### Install the Dependencies @@ -171,7 +169,6 @@ Run the Docker container with the required environment variables: ```bash docker run -d \ -p 3000:3000 \ - -e NEXTAUTH_URL="" -e NEXTAUTH_SECRET="" -e NEXT_PRIVATE_ENCRYPTION_KEY="" -e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="" @@ -200,7 +197,6 @@ The environment variables listed above are a subset of those available for confi | Variable | Description | | -------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `PORT` | The port on which the Documenso application runs. It defaults to `3000`. | -| `NEXTAUTH_URL` | The URL for the NextAuth.js authentication service. | | `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. | | `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). | | `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). | diff --git a/apps/documentation/pages/developers/self-hosting/index.mdx b/apps/documentation/pages/developers/self-hosting/index.mdx index b4aefb848..0da06a66b 100644 --- a/apps/documentation/pages/developers/self-hosting/index.mdx +++ b/apps/documentation/pages/developers/self-hosting/index.mdx @@ -3,7 +3,7 @@ title: Getting Started with Self-Hosting description: A step-by-step guide to setting up and hosting your own Documenso instance. --- -import { CallToAction } from '@documenso/ui/components/call-to-action'; +import { CallToAction } from '../../../components/call-to-action'; # Getting Started with Self-Hosting diff --git a/apps/documentation/pages/developers/webhooks.mdx b/apps/documentation/pages/developers/webhooks.mdx index 3ce2e7ee9..1155e32c8 100644 --- a/apps/documentation/pages/developers/webhooks.mdx +++ b/apps/documentation/pages/developers/webhooks.mdx @@ -21,6 +21,7 @@ Documenso supports Webhooks and allows you to subscribe to the following events: - `document.signed` - `document.completed` - `document.rejected` +- `document.cancelled` ## Create a webhook subscription @@ -37,7 +38,7 @@ Clicking on the "**Create Webhook**" button opens a modal to create a new webhoo To create a new webhook subscription, you need to provide the following information: - Enter the webhook URL that will receive the event payload. -- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`. +- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`. - Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request. ![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp) @@ -528,6 +529,96 @@ Example payload for the `document.rejected` event: } ``` +Example payload for the `document.rejected` event: + +```json +{ + "event": "DOCUMENT_CANCELLED", + "payload": { + "id": 7, + "externalId": null, + "userId": 3, + "authOptions": null, + "formValues": null, + "visibility": "EVERYONE", + "title": "documenso.pdf", + "status": "PENDING", + "documentDataId": "cm6exvn93006hi02ru90a265a", + "createdAt": "2025-01-27T11:02:14.393Z", + "updatedAt": "2025-01-27T11:03:16.387Z", + "completedAt": null, + "deletedAt": null, + "teamId": null, + "templateId": null, + "source": "DOCUMENT", + "documentMeta": { + "id": "cm6exvn96006ji02rqvzjvwoy", + "subject": "", + "message": "", + "timezone": "Etc/UTC", + "password": null, + "dateFormat": "yyyy-MM-dd hh:mm a", + "redirectUrl": "", + "signingOrder": "PARALLEL", + "typedSignatureEnabled": true, + "language": "en", + "distributionMethod": "EMAIL", + "emailSettings": { + "documentDeleted": true, + "documentPending": true, + "recipientSigned": true, + "recipientRemoved": true, + "documentCompleted": true, + "ownerDocumentCompleted": true, + "recipientSigningRequest": true + } + }, + "recipients": [ + { + "id": 7, + "documentId": 7, + "templateId": null, + "email": "mybirihix@mailinator.com", + "name": "Zorita Baird", + "token": "XkKx1HCs6Znm2UBJA2j6o", + "documentDeletedAt": null, + "expired": null, + "signedAt": null, + "authOptions": { "accessAuth": null, "actionAuth": null }, + "signingOrder": 1, + "rejectionReason": null, + "role": "SIGNER", + "readStatus": "NOT_OPENED", + "signingStatus": "NOT_SIGNED", + "sendStatus": "SENT" + } + ], + "Recipient": [ + { + "id": 7, + "documentId": 7, + "templateId": null, + "email": "signer@documenso.com", + "name": "Signer", + "token": "XkKx1HCs6Znm2UBJA2j6o", + "documentDeletedAt": null, + "expired": null, + "signedAt": null, + "authOptions": { "accessAuth": null, "actionAuth": null }, + "signingOrder": 1, + "rejectionReason": null, + "role": "SIGNER", + "readStatus": "NOT_OPENED", + "signingStatus": "NOT_SIGNED", + "sendStatus": "SENT" + } + ] + }, + "createdAt": "2025-01-27T11:03:27.730Z", + "webhookEndpoint": "https://mywebhooksite.com/mywebhook" +} +``` + ## Availability Webhooks are available to individual users and teams. diff --git a/apps/documentation/pages/users/signing-documents/index.mdx b/apps/documentation/pages/users/signing-documents/index.mdx index a0a32399d..f37afc8f7 100644 --- a/apps/documentation/pages/users/signing-documents/index.mdx +++ b/apps/documentation/pages/users/signing-documents/index.mdx @@ -85,12 +85,13 @@ You can also set the recipient's role, which determines their actions and permis Documenso has 4 roles for recipients with different permissions and actions. -| Role | Function | Action required | Signature | -| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: | -| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes | -| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional | -| Viewer | Needs to confirm they viewed the document. | Yes | No | -| BCC | Receives a copy of the signed document after completion. No action is required. | No | No | +| Role | Function | Action required | Signature | +| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: | +| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes | +| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional | +| Viewer | Needs to confirm they viewed the document. | Yes | No | +| Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No | +| CC | Receives a copy of the signed document after completion. No action is required. | No | No | ### Fields diff --git a/apps/openpage-api/lib/growth/get-monthly-completed-document.ts b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts index 1b4c83650..885842101 100644 --- a/apps/openpage-api/lib/growth/get-monthly-completed-document.ts +++ b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts @@ -1,7 +1,7 @@ +import { DocumentStatus } from '@prisma/client'; import { DateTime } from 'luxon'; import { kyselyPrisma, sql } from '@documenso/prisma'; -import { DocumentStatus } from '@documenso/prisma/client'; export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => { const qb = kyselyPrisma.$kysely diff --git a/apps/openpage-api/package.json b/apps/openpage-api/package.json index 1a8816acb..3ceaba3e4 100644 --- a/apps/openpage-api/package.json +++ b/apps/openpage-api/package.json @@ -7,8 +7,7 @@ "build": "next build", "start": "next start", "lint:fix": "next lint --fix", - "clean": "rimraf .next && rimraf node_modules", - "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" + "clean": "rimraf .next && rimraf node_modules" }, "dependencies": { "@documenso/prisma": "*", diff --git a/apps/remix/.bin/build.sh b/apps/remix/.bin/build.sh new file mode 100755 index 000000000..d7e4c6134 --- /dev/null +++ b/apps/remix/.bin/build.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env sh + +# Exit on error. +set -eo pipefail + +SCRIPT_DIR="$(readlink -f "$(dirname "$0")")" +WEB_APP_DIR="$SCRIPT_DIR/.." + +# Store the original directory +ORIGINAL_DIR=$(pwd) + +# Set up trap to ensure we return to original directory +trap 'cd "$ORIGINAL_DIR"' EXIT + +cd "$WEB_APP_DIR" + +start_time=$(date +%s) + +echo "[Build]: Extracting and compiling translations" +npm run translate --prefix ../../ + +echo "[Build]: Building app" +npm run build:app + +echo "[Build]: Building server" +npm run build:server + +# Copy over the entry point for the server. +cp server/main.js build/server/main.js + +# Copy over all web.js translations +cp -r ../../packages/lib/translations build/server/hono/packages/lib/translations + +# Time taken +end_time=$(date +%s) + +echo "[Build]: Done in $((end_time - start_time)) seconds" \ No newline at end of file diff --git a/apps/remix/.bin/stripe-dev.sh b/apps/remix/.bin/stripe-dev.sh new file mode 100755 index 000000000..67d349ded --- /dev/null +++ b/apps/remix/.bin/stripe-dev.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Set Error handling +set -eu + +SCRIPT_DIR="$(readlink -f "$(dirname "$0")")" +WEB_APP_DIR="$SCRIPT_DIR/.." + +# Store the original directory +ORIGINAL_DIR=$(pwd) + +# Set up trap to ensure we return to original directory +trap 'cd "$ORIGINAL_DIR"' EXIT + +cd "$WEB_APP_DIR" + +# Define env file paths +ENV_LOCAL_FILE="../../.env.local" + +# Function to load environment variable from env files +load_env_var() { + local var_name=$1 + local var_value="" + + if [ -f "$ENV_LOCAL_FILE" ]; then + var_value=$(grep "^$var_name=" "$ENV_LOCAL_FILE" | cut -d '=' -f2) + fi + + # Remove quotes if present + var_value=$(echo "$var_value" | sed 's/^"\(.*\)"$/\1/' | sed "s/^'\(.*\)'$/\1/") + + echo "$var_value" +} + +NEXT_PUBLIC_FEATURE_BILLING_ENABLED=$(load_env_var "NEXT_PUBLIC_FEATURE_BILLING_ENABLED") + +# Check if NEXT_PUBLIC_FEATURE_BILLING_ENABLED is equal to true +if [ "$NEXT_PUBLIC_FEATURE_BILLING_ENABLED" != "true" ]; then + echo "[ERROR]: NEXT_PUBLIC_FEATURE_BILLING_ENABLED must be enabled." + exit 1 +fi + +# 1. Load NEXT_PRIVATE_STRIPE_API_KEY from env files +NEXT_PRIVATE_STRIPE_API_KEY=$(load_env_var "NEXT_PRIVATE_STRIPE_API_KEY") + +# Check if NEXT_PRIVATE_STRIPE_API_KEY exists +if [ -z "$NEXT_PRIVATE_STRIPE_API_KEY" ]; then + echo "[ERROR]: NEXT_PRIVATE_STRIPE_API_KEY not found in environment files." + echo "[ERROR]: Please make sure it's set in $ENV_LOCAL_FILE" + exit 1 +fi + +# 2. Check if stripe CLI is installed +if ! command -v stripe &> /dev/null; then + echo "[ERROR]: Stripe CLI is not installed or not in PATH." + echo "[ERROR]: Please install the Stripe CLI: https://stripe.com/docs/stripe-cli" + exit 1 +fi + +# 3. Check if NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET env key exists +NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=$(load_env_var "NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET") + +if [ -z "$NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET" ]; then + echo "╔═════════════════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ ! WARNING: NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET MISSING ! ║" + echo "║ ║" + echo "║ Copy the webhook signing secret which will appear in the terminal ║" + echo "║ soon into the env file. ║" + echo "║ ║" + echo "║ The webhook secret will start with whsec_... ║" + echo "║ ║" + echo "╚═════════════════════════════════════════════════════════════════════╝" +fi + +echo "[INFO]: Starting Stripe webhook listener..." +stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to http://localhost:3000/api/stripe/webhook diff --git a/apps/remix/.dockerignore b/apps/remix/.dockerignore new file mode 100644 index 000000000..9b8d51471 --- /dev/null +++ b/apps/remix/.dockerignore @@ -0,0 +1,4 @@ +.react-router +build +node_modules +README.md \ No newline at end of file diff --git a/apps/remix/.gitignore b/apps/remix/.gitignore new file mode 100644 index 000000000..188507428 --- /dev/null +++ b/apps/remix/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/node_modules/ + +# React Router +/.react-router/ +/build/ + +# Vite +vite.config.*.timestamp* \ No newline at end of file diff --git a/apps/remix/Dockerfile b/apps/remix/Dockerfile new file mode 100644 index 000000000..207bf937e --- /dev/null +++ b/apps/remix/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS development-dependencies-env +COPY . /app +WORKDIR /app +RUN npm ci + +FROM node:20-alpine AS production-dependencies-env +COPY ./package.json package-lock.json /app/ +WORKDIR /app +RUN npm ci --omit=dev + +FROM node:20-alpine AS build-env +COPY . /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN npm run build + +FROM node:20-alpine +COPY ./package.json package-lock.json /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +WORKDIR /app +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/apps/remix/Dockerfile.bun b/apps/remix/Dockerfile.bun new file mode 100644 index 000000000..973038e8a --- /dev/null +++ b/apps/remix/Dockerfile.bun @@ -0,0 +1,25 @@ +FROM oven/bun:1 AS dependencies-env +COPY . /app + +FROM dependencies-env AS development-dependencies-env +COPY ./package.json bun.lockb /app/ +WORKDIR /app +RUN bun i --frozen-lockfile + +FROM dependencies-env AS production-dependencies-env +COPY ./package.json bun.lockb /app/ +WORKDIR /app +RUN bun i --production + +FROM dependencies-env AS build-env +COPY ./package.json bun.lockb /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN bun run build + +FROM dependencies-env +COPY ./package.json bun.lockb /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +WORKDIR /app +CMD ["bun", "run", "start"] \ No newline at end of file diff --git a/apps/remix/Dockerfile.pnpm b/apps/remix/Dockerfile.pnpm new file mode 100644 index 000000000..57916afc2 --- /dev/null +++ b/apps/remix/Dockerfile.pnpm @@ -0,0 +1,26 @@ +FROM node:20-alpine AS dependencies-env +RUN npm i -g pnpm +COPY . /app + +FROM dependencies-env AS development-dependencies-env +COPY ./package.json pnpm-lock.yaml /app/ +WORKDIR /app +RUN pnpm i --frozen-lockfile + +FROM dependencies-env AS production-dependencies-env +COPY ./package.json pnpm-lock.yaml /app/ +WORKDIR /app +RUN pnpm i --prod --frozen-lockfile + +FROM dependencies-env AS build-env +COPY ./package.json pnpm-lock.yaml /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN pnpm build + +FROM dependencies-env +COPY ./package.json pnpm-lock.yaml /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +WORKDIR /app +CMD ["pnpm", "start"] \ No newline at end of file diff --git a/apps/remix/README.md b/apps/remix/README.md new file mode 100644 index 000000000..e0d20664e --- /dev/null +++ b/apps/remix/README.md @@ -0,0 +1,100 @@ +# Welcome to React Router! + +A modern, production-ready template for building full-stack React applications using React Router. + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default) + +## Features + +- 🚀 Server-side rendering +- ⚡️ Hot Module Replacement (HMR) +- 📦 Asset bundling and optimization +- 🔄 Data loading and mutations +- 🔒 TypeScript by default +- 🎉 TailwindCSS for styling +- 📖 [React Router docs](https://reactrouter.com/) + +## Getting Started + +### Installation + +Install the dependencies: + +```bash +npm install +``` + +### Development + +Start the development server with HMR: + +```bash +npm run dev +``` + +Your application will be available at `http://localhost:5173`. + +## Building for Production + +Create a production build: + +```bash +npm run build +``` + +## Deployment + +### Docker Deployment + +This template includes three Dockerfiles optimized for different package managers: + +- `Dockerfile` - for npm +- `Dockerfile.pnpm` - for pnpm +- `Dockerfile.bun` - for bun + +To build and run using Docker: + +```bash +# For npm +docker build -t my-app . + +# For pnpm +docker build -f Dockerfile.pnpm -t my-app . + +# For bun +docker build -f Dockerfile.bun -t my-app . + +# Run the container +docker run -p 3000:3000 my-app +``` + +The containerized application can be deployed to any platform that supports Docker, including: + +- AWS ECS +- Google Cloud Run +- Azure Container Apps +- Digital Ocean App Platform +- Fly.io +- Railway + +### DIY Deployment + +If you're familiar with deploying Node applications, the built-in app server is production-ready. + +Make sure to deploy the output of `npm run build` + +``` +├── package.json +├── package-lock.json (or pnpm-lock.yaml, or bun.lockb) +├── build/ +│ ├── client/ # Static assets +│ └── server/ # Server-side code +``` + +## Styling + +This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. + +--- + +Built with ❤️ using React Router. diff --git a/apps/remix/app/app.css b/apps/remix/app/app.css new file mode 100644 index 000000000..529edec77 --- /dev/null +++ b/apps/remix/app/app.css @@ -0,0 +1,24 @@ +@import '@documenso/ui/styles/theme.css'; + +@font-face { + font-family: 'Inter'; + src: url('/public/fonts/inter-regular.ttf') format('ttf'); + /* font-weight: 400; + font-style: normal; + font-display: swap; */ +} + +@font-face { + font-family: 'Caveat'; + src: url('/public/fonts/caveat.ttf') format('ttf'); + /* font-weight: 400; + font-style: normal; + font-display: swap; */ +} + +@layer base { + :root { + --font-sans: 'Inter'; + --font-signature: 'Caveat'; + } +} diff --git a/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx b/apps/remix/app/components/dialogs/account-delete-dialog.tsx similarity index 91% rename from apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx rename to apps/remix/app/components/dialogs/account-delete-dialog.tsx index 2bb37e57b..5c528dfc4 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx +++ b/apps/remix/app/components/dialogs/account-delete-dialog.tsx @@ -1,12 +1,11 @@ -'use client'; - import { useState } from 'react'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { signOut } from 'next-auth/react'; +import { Trans } from '@lingui/react/macro'; -import type { User } from '@documenso/prisma/client'; +import { authClient } from '@documenso/auth/client'; +import { useSession } from '@documenso/lib/client-only/providers/session'; import { trpc } from '@documenso/trpc/react'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; @@ -23,12 +22,13 @@ import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type DeleteAccountDialogProps = { +export type AccountDeleteDialogProps = { className?: string; - user: User; }; -export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => { +export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) => { + const { user } = useSession(); + const { _ } = useLingui(); const { toast } = useToast(); @@ -49,7 +49,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp duration: 5000, }); - return await signOut({ callbackUrl: '/' }); + return await authClient.signOut(); } catch (err) { toast({ title: _(msg`An unknown error occurred`), @@ -118,7 +118,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp {!hasTwoFactorAuthentication && ( -
+
) : (
- +
)} diff --git a/apps/web/src/app/(dashboard)/documents/move-document-dialog.tsx b/apps/remix/app/components/dialogs/document-move-dialog.tsx similarity index 85% rename from apps/web/src/app/(dashboard)/documents/move-document-dialog.tsx rename to apps/remix/app/components/dialogs/document-move-dialog.tsx index abdd9d817..1e0632531 100644 --- a/apps/web/src/app/(dashboard)/documents/move-document-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-move-dialog.tsx @@ -1,11 +1,10 @@ import { useState } from 'react'; -import { useRouter } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { trpc } from '@documenso/trpc/react'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; import { Button } from '@documenso/ui/primitives/button'; @@ -26,30 +25,28 @@ import { } from '@documenso/ui/primitives/select'; import { useToast } from '@documenso/ui/primitives/use-toast'; -type MoveDocumentDialogProps = { +type DocumentMoveDialogProps = { documentId: number; open: boolean; onOpenChange: (_open: boolean) => void; }; -export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocumentDialogProps) => { +export const DocumentMoveDialog = ({ documentId, open, onOpenChange }: DocumentMoveDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); - const router = useRouter(); - const [selectedTeamId, setSelectedTeamId] = useState(null); - const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery(); + const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery(); const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({ onSuccess: () => { - router.refresh(); toast({ title: _(msg`Document moved`), description: _(msg`The document has been successfully moved to the selected team.`), duration: 5000, }); + onOpenChange(false); }, onError: (error) => { @@ -97,9 +94,7 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
{team.avatarImageId && ( - + )} diff --git a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx similarity index 89% rename from apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx rename to apps/remix/app/components/dialogs/document-resend-dialog.tsx index f45a3262f..bcd1d61a3 100644 --- a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -1,19 +1,18 @@ -'use client'; - import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { Team } from '@prisma/client'; +import { type Document, type Recipient, SigningStatus } from '@prisma/client'; import { History } from 'lucide-react'; -import { useSession } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; +import { useSession } from '@documenso/lib/client-only/providers/session'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import type { Team } from '@documenso/prisma/client'; -import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -37,16 +36,17 @@ import { } from '@documenso/ui/primitives/form/form'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar'; +import { useOptionalCurrentTeam } from '~/providers/team'; + +import { StackAvatar } from '../general/stack-avatar'; const FORM_ID = 'resend-email'; -export type ResendDocumentActionItemProps = { +export type DocumentResendDialogProps = { document: Document & { team: Pick | null; }; recipients: Recipient[]; - team?: Pick; }; export const ZResendDocumentFormSchema = z.object({ @@ -57,17 +57,15 @@ export const ZResendDocumentFormSchema = z.object({ export type TResendDocumentFormSchema = z.infer; -export const ResendDocumentActionItem = ({ - document, - recipients, - team, -}: ResendDocumentActionItemProps) => { - const { data: session } = useSession(); +export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => { + const { user } = useSession(); + const team = useOptionalCurrentTeam(); + const { toast } = useToast(); const { _ } = useLingui(); const [isOpen, setIsOpen] = useState(false); - const isOwner = document.userId === session?.user?.id; + const isOwner = document.userId === user.id; const isCurrentTeamDocument = team && document.team?.url === team.url; const isDisabled = diff --git a/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx b/apps/remix/app/components/dialogs/passkey-create-dialog.tsx similarity index 97% rename from apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx rename to apps/remix/app/components/dialogs/passkey-create-dialog.tsx index 44b87ae2f..6a21895d3 100644 --- a/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx +++ b/apps/remix/app/components/dialogs/passkey-create-dialog.tsx @@ -1,10 +1,9 @@ -'use client'; - import { useEffect, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; import { startRegistration } from '@simplewebauthn/browser'; import { KeyRoundIcon } from 'lucide-react'; @@ -38,7 +37,7 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type CreatePasskeyDialogProps = { +export type PasskeyCreateDialogProps = { trigger?: React.ReactNode; onSuccess?: () => void; } & Omit; @@ -51,7 +50,7 @@ type TCreatePasskeyFormSchema = z.infer; const parser = new UAParser(); -export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => { +export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCreateDialogProps) => { const [open, setOpen] = useState(false); const [formError, setFormError] = useState(null); diff --git a/apps/web/src/components/templates/manage-public-template-dialog.tsx b/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx similarity index 98% rename from apps/web/src/components/templates/manage-public-template-dialog.tsx rename to apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx index 67ac27782..d6a13f456 100644 --- a/apps/web/src/components/templates/manage-public-template-dialog.tsx +++ b/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx @@ -1,18 +1,17 @@ -'use client'; - import { useEffect, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Plural, Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Plural, Trans } from '@lingui/react/macro'; +import type { Template, TemplateDirectLink } from '@prisma/client'; +import { TemplateType } from '@prisma/client'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; import { CheckCircle2Icon, CircleIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { P, match } from 'ts-pattern'; import { z } from 'zod'; -import type { Template, TemplateDirectLink } from '@documenso/prisma/client'; -import { TemplateType } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH, diff --git a/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx b/apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx similarity index 96% rename from apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx rename to apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx index 9a66c7073..038c78504 100644 --- a/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx @@ -1,7 +1,8 @@ import { useMemo, useState } from 'react'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; import { AnimatePresence, motion } from 'framer-motion'; import { Loader, TagIcon } from 'lucide-react'; @@ -20,18 +21,18 @@ import { import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type CreateTeamCheckoutDialogProps = { +export type TeamCheckoutCreateDialogProps = { pendingTeamId: number | null; onClose: () => void; } & Omit; const MotionCard = motion(Card); -export const CreateTeamCheckoutDialog = ({ +export const TeamCheckoutCreateDialog = ({ pendingTeamId, onClose, ...props -}: CreateTeamCheckoutDialogProps) => { +}: TeamCheckoutCreateDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); diff --git a/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx b/apps/remix/app/components/dialogs/team-create-dialog.tsx similarity index 91% rename from apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx rename to apps/remix/app/components/dialogs/team-create-dialog.tsx index bff29a493..0b49e9b6d 100644 --- a/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-create-dialog.tsx @@ -1,18 +1,17 @@ -'use client'; - import { useEffect, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; import { useForm } from 'react-hook-form'; +import { useSearchParams } from 'react-router'; +import { useNavigate } from 'react-router'; import type { z } from 'zod'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; import { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema'; @@ -37,7 +36,7 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type CreateTeamDialogProps = { +export type TeamCreateDialogProps = { trigger?: React.ReactNode; } & Omit; @@ -48,12 +47,12 @@ const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({ type TCreateTeamFormSchema = z.infer; -export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) => { +export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); - const router = useRouter(); - const searchParams = useSearchParams(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const updateSearchParams = useUpdateSearchParams(); const [open, setOpen] = useState(false); @@ -80,7 +79,7 @@ export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) = setOpen(false); if (response.paymentRequired) { - router.push(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`); + await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`); return; } @@ -120,7 +119,7 @@ export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) = setOpen(true); updateSearchParams({ action: null }); } - }, [actionSearchParam, open, setOpen, updateSearchParams]); + }, [actionSearchParam, open]); useEffect(() => { form.reset(); @@ -201,7 +200,7 @@ export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) = {!form.formState.errors.teamUrl && ( {field.value ? ( - `${WEBAPP_BASE_URL}/t/${field.value}` + `${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}` ) : ( A unique URL to identify your team )} diff --git a/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx b/apps/remix/app/components/dialogs/team-delete-dialog.tsx similarity index 93% rename from apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx rename to apps/remix/app/components/dialogs/team-delete-dialog.tsx index 3377bc989..b297cbb2a 100644 --- a/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-delete-dialog.tsx @@ -1,13 +1,11 @@ -'use client'; - import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; import { z } from 'zod'; import { AppError } from '@documenso/lib/errors/app-error'; @@ -34,14 +32,14 @@ import { Input } from '@documenso/ui/primitives/input'; import type { Toast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type DeleteTeamDialogProps = { +export type TeamDeleteDialogProps = { teamId: number; teamName: string; trigger?: React.ReactNode; }; -export const DeleteTeamDialog = ({ trigger, teamId, teamName }: DeleteTeamDialogProps) => { - const router = useRouter(); +export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialogProps) => { + const navigate = useNavigate(); const [open, setOpen] = useState(false); const { _ } = useLingui(); @@ -74,9 +72,9 @@ export const DeleteTeamDialog = ({ trigger, teamId, teamName }: DeleteTeamDialog duration: 5000, }); - setOpen(false); + await navigate('/settings/teams'); - router.push('/settings/teams'); + setOpen(false); } catch (err) { const error = AppError.parseError(err); diff --git a/apps/web/src/components/(teams)/dialogs/add-team-email-dialog.tsx b/apps/remix/app/components/dialogs/team-email-add-dialog.tsx similarity index 94% rename from apps/web/src/components/(teams)/dialogs/add-team-email-dialog.tsx rename to apps/remix/app/components/dialogs/team-email-add-dialog.tsx index 23c23f751..161c2c0eb 100644 --- a/apps/web/src/components/(teams)/dialogs/add-team-email-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-add-dialog.tsx @@ -1,15 +1,13 @@ -'use client'; - import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; import { Plus } from 'lucide-react'; import { useForm } from 'react-hook-form'; +import { useRevalidator } from 'react-router'; import type { z } from 'zod'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; @@ -36,7 +34,7 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type AddTeamEmailDialogProps = { +export type TeamEmailAddDialogProps = { teamId: number; trigger?: React.ReactNode; } & Omit; @@ -48,13 +46,12 @@ const ZCreateTeamEmailFormSchema = ZCreateTeamEmailVerificationMutationSchema.pi type TCreateTeamEmailFormSchema = z.infer; -export const AddTeamEmailDialog = ({ teamId, trigger, ...props }: AddTeamEmailDialogProps) => { - const router = useRouter(); - +export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDialogProps) => { const [open, setOpen] = useState(false); const { _ } = useLingui(); const { toast } = useToast(); + const { revalidate } = useRevalidator(); const form = useForm({ resolver: zodResolver(ZCreateTeamEmailFormSchema), @@ -81,7 +78,7 @@ export const AddTeamEmailDialog = ({ teamId, trigger, ...props }: AddTeamEmailDi duration: 5000, }); - router.refresh(); + await revalidate(); setOpen(false); } catch (err) { diff --git a/apps/web/src/components/(teams)/dialogs/remove-team-email-dialog.tsx b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx similarity index 89% rename from apps/web/src/components/(teams)/dialogs/remove-team-email-dialog.tsx rename to apps/remix/app/components/dialogs/team-email-delete-dialog.tsx index 0496f923a..ec050961c 100644 --- a/apps/web/src/components/(teams)/dialogs/remove-team-email-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx @@ -1,15 +1,13 @@ -'use client'; - import { useState } from 'react'; -import { useRouter } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { Prisma } from '@prisma/client'; +import { useRevalidator } from 'react-router'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; -import type { Prisma } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { Alert } from '@documenso/ui/primitives/alert'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; @@ -25,7 +23,7 @@ import { } from '@documenso/ui/primitives/dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type RemoveTeamEmailDialogProps = { +export type TeamEmailDeleteDialogProps = { trigger?: React.ReactNode; teamName: string; team: Prisma.TeamGetPayload<{ @@ -42,13 +40,12 @@ export type RemoveTeamEmailDialogProps = { }>; }; -export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEmailDialogProps) => { +export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDeleteDialogProps) => { const [open, setOpen] = useState(false); const { _ } = useLingui(); const { toast } = useToast(); - - const router = useRouter(); + const { revalidate } = useRevalidator(); const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } = trpc.team.deleteTeamEmail.useMutation({ @@ -97,7 +94,7 @@ export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEma await deleteTeamEmailVerification({ teamId: team.id }); } - router.refresh(); + await revalidate(); }; return ( @@ -127,7 +124,7 @@ export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEma ; @@ -45,17 +43,16 @@ const ZUpdateTeamEmailFormSchema = z.object({ type TUpdateTeamEmailFormSchema = z.infer; -export const UpdateTeamEmailDialog = ({ +export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props -}: UpdateTeamEmailDialogProps) => { - const router = useRouter(); - +}: TeamEmailUpdateDialogProps) => { const [open, setOpen] = useState(false); const { _ } = useLingui(); const { toast } = useToast(); + const { revalidate } = useRevalidator(); const form = useForm({ resolver: zodResolver(ZUpdateTeamEmailFormSchema), @@ -81,7 +78,7 @@ export const UpdateTeamEmailDialog = ({ duration: 5000, }); - router.refresh(); + await revalidate(); setOpen(false); } catch (err) { diff --git a/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx b/apps/remix/app/components/dialogs/team-leave-dialog.tsx similarity index 88% rename from apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx rename to apps/remix/app/components/dialogs/team-leave-dialog.tsx index 3689d5e92..a6b6246a6 100644 --- a/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-leave-dialog.tsx @@ -1,13 +1,12 @@ -'use client'; - import { useState } from 'react'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { TeamMemberRole } from '@prisma/client'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; -import type { TeamMemberRole } from '@documenso/prisma/client'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { trpc } from '@documenso/trpc/react'; import { Alert } from '@documenso/ui/primitives/alert'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; @@ -23,7 +22,7 @@ import { } from '@documenso/ui/primitives/dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type LeaveTeamDialogProps = { +export type TeamLeaveDialogProps = { teamId: number; teamName: string; teamAvatarImageId?: string | null; @@ -31,13 +30,13 @@ export type LeaveTeamDialogProps = { trigger?: React.ReactNode; }; -export const LeaveTeamDialog = ({ +export const TeamLeaveDialog = ({ trigger, teamId, teamName, teamAvatarImageId, role, -}: LeaveTeamDialogProps) => { +}: TeamLeaveDialogProps) => { const [open, setOpen] = useState(false); const { _ } = useLingui(); @@ -89,7 +88,7 @@ export const LeaveTeamDialog = ({ { +}: TeamMemberDeleteDialogProps) => { const [open, setOpen] = useState(false); const { _ } = useLingui(); diff --git a/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx b/apps/remix/app/components/dialogs/team-member-invite-dialog.tsx similarity index 94% rename from apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx rename to apps/remix/app/components/dialogs/team-member-invite-dialog.tsx index db17a23a7..dac4f8fce 100644 --- a/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-member-invite-dialog.tsx @@ -1,10 +1,10 @@ -'use client'; - import { useEffect, useRef, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { TeamMemberRole } from '@prisma/client'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react'; import Papa, { type ParseResult } from 'papaparse'; @@ -13,7 +13,6 @@ import { z } from 'zod'; import { downloadFile } from '@documenso/lib/client-only/download-file'; import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; -import { TeamMemberRole } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema'; import { cn } from '@documenso/ui/lib/utils'; @@ -47,9 +46,9 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type InviteTeamMembersDialogProps = { - currentUserTeamRole: TeamMemberRole; - teamId: number; +import { useCurrentTeam } from '~/providers/team'; + +export type TeamMemberInviteDialogProps = { trigger?: React.ReactNode; } & Omit; @@ -96,12 +95,7 @@ const ZImportTeamMemberSchema = z.array( }), ); -export const InviteTeamMembersDialog = ({ - currentUserTeamRole, - teamId, - trigger, - ...props -}: InviteTeamMembersDialogProps) => { +export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDialogProps) => { const [open, setOpen] = useState(false); const fileInputRef = useRef(null); const [invitationType, setInvitationType] = useState('INDIVIDUAL'); @@ -109,6 +103,8 @@ export const InviteTeamMembersDialog = ({ const { _ } = useLingui(); const { toast } = useToast(); + const team = useCurrentTeam(); + const form = useForm({ resolver: zodResolver(ZInviteTeamMembersFormSchema), defaultValues: { @@ -142,7 +138,7 @@ export const InviteTeamMembersDialog = ({ const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => { try { await createTeamMemberInvites({ - teamId, + teamId: team.id, invitations, }); @@ -204,7 +200,7 @@ export const InviteTeamMembersDialog = ({ setInvitationType('INDIVIDUAL'); } catch (err) { - console.error(err.message); + console.error(err); toast({ title: _(msg`Something went wrong`), @@ -325,11 +321,13 @@ export const InviteTeamMembersDialog = ({ - {TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => ( - - {_(TEAM_MEMBER_ROLE_MAP[role]) ?? role} - - ))} + {TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamMember.role].map( + (role) => ( + + {_(TEAM_MEMBER_ROLE_MAP[role]) ?? role} + + ), + )} diff --git a/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx b/apps/remix/app/components/dialogs/team-member-update-dialog.tsx similarity index 95% rename from apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx rename to apps/remix/app/components/dialogs/team-member-update-dialog.tsx index 88313e2e4..e9c3a021b 100644 --- a/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-member-update-dialog.tsx @@ -1,17 +1,16 @@ -'use client'; - import { useEffect, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { TeamMemberRole } from '@prisma/client'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; -import { TeamMemberRole } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -40,7 +39,7 @@ import { } from '@documenso/ui/primitives/select'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type UpdateTeamMemberDialogProps = { +export type TeamMemberUpdateDialogProps = { currentUserTeamRole: TeamMemberRole; trigger?: React.ReactNode; teamId: number; @@ -55,7 +54,7 @@ const ZUpdateTeamMemberFormSchema = z.object({ type ZUpdateTeamMemberSchema = z.infer; -export const UpdateTeamMemberDialog = ({ +export const TeamMemberUpdateDialog = ({ currentUserTeamRole, trigger, teamId, @@ -63,7 +62,7 @@ export const UpdateTeamMemberDialog = ({ teamMemberName, teamMemberRole, ...props -}: UpdateTeamMemberDialogProps) => { +}: TeamMemberUpdateDialogProps) => { const [open, setOpen] = useState(false); const { _ } = useLingui(); diff --git a/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx b/apps/remix/app/components/dialogs/team-transfer-dialog.tsx similarity index 96% rename from apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx rename to apps/remix/app/components/dialogs/team-transfer-dialog.tsx index fa991099b..4e46233cc 100644 --- a/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-transfer-dialog.tsx @@ -1,14 +1,12 @@ -'use client'; - import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; +import { useRevalidator } from 'react-router'; import { z } from 'zod'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; @@ -42,24 +40,24 @@ import { } from '@documenso/ui/primitives/select'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type TransferTeamDialogProps = { +export type TeamTransferDialogProps = { teamId: number; teamName: string; ownerUserId: number; trigger?: React.ReactNode; }; -export const TransferTeamDialog = ({ +export const TeamTransferDialog = ({ trigger, teamId, teamName, ownerUserId, -}: TransferTeamDialogProps) => { - const router = useRouter(); +}: TeamTransferDialogProps) => { const [open, setOpen] = useState(false); const { _ } = useLingui(); const { toast } = useToast(); + const { revalidate } = useRevalidator(); const { mutateAsync: requestTeamOwnershipTransfer } = trpc.team.requestTeamOwnershipTransfer.useMutation(); @@ -67,7 +65,7 @@ export const TransferTeamDialog = ({ const { data, refetch: refetchTeamMembers, - isLoading: loadingTeamMembers, + isPending: loadingTeamMembers, isLoadingError: loadingTeamMembersError, } = trpc.team.getTeamMembers.useQuery({ teamId, @@ -102,7 +100,7 @@ export const TransferTeamDialog = ({ clearPaymentMethods, }); - router.refresh(); + await revalidate(); toast({ title: _(msg`Success`), diff --git a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx new file mode 100644 index 000000000..d210550c6 --- /dev/null +++ b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx @@ -0,0 +1,274 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { File as FileIcon, Upload, X } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useOptionalCurrentTeam } from '~/providers/team'; + +const ZBulkSendFormSchema = z.object({ + file: z.instanceof(File), + sendImmediately: z.boolean().default(false), +}); + +type TBulkSendFormSchema = z.infer; + +export type TemplateBulkSendDialogProps = { + templateId: number; + recipients: Array<{ email: string; name?: string | null }>; + trigger?: React.ReactNode; + onSuccess?: () => void; +}; + +export const TemplateBulkSendDialog = ({ + templateId, + recipients, + trigger, + onSuccess, +}: TemplateBulkSendDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const team = useOptionalCurrentTeam(); + + const form = useForm({ + resolver: zodResolver(ZBulkSendFormSchema), + defaultValues: { + sendImmediately: false, + }, + }); + + const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation(); + + const onDownloadTemplate = () => { + const headers = recipients.flatMap((_, index) => [ + `recipient_${index + 1}_email`, + `recipient_${index + 1}_name`, + ]); + + const exampleRow = recipients.flatMap((recipient) => [recipient.email, recipient.name || '']); + + const csv = [headers.join(','), exampleRow.join(',')].join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + + const a = Object.assign(document.createElement('a'), { + href: url, + download: 'template.csv', + }); + + a.click(); + + window.URL.revokeObjectURL(url); + }; + + const onSubmit = async (values: TBulkSendFormSchema) => { + try { + const csv = await values.file.text(); + + await uploadBulkSend({ + templateId, + teamId: team?.id, + csv: csv, + sendImmediately: values.sendImmediately, + }); + + toast({ + title: _(msg`Success`), + description: _( + msg`Your bulk send has been initiated. You will receive an email notification upon completion.`, + ), + }); + + form.reset(); + onSuccess?.(); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'Failed to upload CSV. Please check the file format and try again.', + variant: 'destructive', + }); + } + }; + + return ( + + + {trigger ?? ( + + )} + + + + + + Bulk Send Template via CSV + + + + + Upload a CSV file to create multiple documents from this template. Each row represents + one document with its recipient details. + + + + +
+ +
+

+ CSV Structure +

+ +

+ + For each recipient, provide their email (required) and name (optional) in separate + columns. Download the template CSV below for the correct format. + +

+ +

+ Current recipients: +

+ +
    + {recipients.map((recipient, index) => ( +
  • + {recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email} +
  • + ))} +
+
+ +
+ + +

+ Pre-formatted CSV template with example data. +

+
+ + ( + + + {!value ? ( + + ) : ( +
+
+ + {value.name} +
+ + +
+ )} +
+ + {error &&

{error.message}

} + +

+ + Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use + template defaults. + +

+
+ )} + /> + + ( + + +
+ + + +
+
+
+ )} + /> + + + + + + + + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx b/apps/remix/app/components/dialogs/template-create-dialog.tsx similarity index 72% rename from apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx rename to apps/remix/app/components/dialogs/template-create-dialog.tsx index f6e31c3e0..e623c806b 100644 --- a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-create-dialog.tsx @@ -1,15 +1,12 @@ -'use client'; +import { useState } from 'react'; -import React, { useState } from 'react'; - -import { useRouter } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { FilePlus, Loader } from 'lucide-react'; -import { useSession } from 'next-auth/react'; +import { useNavigate } from 'react-router'; -import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; +import { useSession } from '@documenso/lib/client-only/providers/session'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; @@ -26,21 +23,21 @@ import { import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { useToast } from '@documenso/ui/primitives/use-toast'; -type NewTemplateDialogProps = { +type TemplateCreateDialogProps = { teamId?: number; templateRootPath: string; }; -export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps) => { - const router = useRouter(); +export const TemplateCreateDialog = ({ templateRootPath }: TemplateCreateDialogProps) => { + const navigate = useNavigate(); - const { data: session } = useSession(); + const { user } = useSession(); const { toast } = useToast(); const { _ } = useLingui(); const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation(); - const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false); + const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false); const [isUploadingFile, setIsUploadingFile] = useState(false); const onFileDrop = async (file: File) => { @@ -51,15 +48,11 @@ export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps) setIsUploadingFile(true); try { - const { type, data } = await putPdfFile(file); - const { id: templateDocumentDataId } = await createDocumentData({ - type, - data, - }); + const response = await putPdfFile(file); const { id } = await createTemplate({ title: file.name, - templateDocumentDataId, + templateDocumentDataId: response.id, }); toast({ @@ -70,9 +63,9 @@ export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps) duration: 5000, }); - setShowNewTemplateDialog(false); + setShowTemplateCreateDialog(false); - router.push(`${templateRootPath}/${id}/edit`); + await navigate(`${templateRootPath}/${id}/edit`); } catch { toast({ title: _(msg`Something went wrong`), @@ -86,11 +79,11 @@ export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps) return ( !isUploadingFile && setShowNewTemplateDialog(value)} + open={showTemplateCreateDialog} + onOpenChange={(value) => !isUploadingFile && setShowTemplateCreateDialog(value)} > - diff --git a/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx b/apps/remix/app/components/dialogs/template-delete-dialog.tsx similarity index 86% rename from apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx rename to apps/remix/app/components/dialogs/template-delete-dialog.tsx index f5a4750b4..7f821d4a3 100644 --- a/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-delete-dialog.tsx @@ -1,7 +1,6 @@ -import { useRouter } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; @@ -15,22 +14,25 @@ import { } from '@documenso/ui/primitives/dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; -type DeleteTemplateDialogProps = { +type TemplateDeleteDialogProps = { id: number; - teamId?: number; open: boolean; onOpenChange: (_open: boolean) => void; + onDelete?: () => Promise | void; }; -export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => { - const router = useRouter(); - +export const TemplateDeleteDialog = ({ + id, + open, + onOpenChange, + onDelete, +}: TemplateDeleteDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); const { mutateAsync: deleteTemplate, isPending } = trpcReact.template.deleteTemplate.useMutation({ - onSuccess: () => { - router.refresh(); + onSuccess: async () => { + await onDelete?.(); toast({ title: _(msg`Template deleted`), diff --git a/apps/web/src/app/(dashboard)/templates/[id]/template-direct-link-dialog-wrapper.tsx b/apps/remix/app/components/dialogs/template-direct-link-dialog-wrapper.tsx similarity index 80% rename from apps/web/src/app/(dashboard)/templates/[id]/template-direct-link-dialog-wrapper.tsx rename to apps/remix/app/components/dialogs/template-direct-link-dialog-wrapper.tsx index 3a00c302c..60ff06715 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/template-direct-link-dialog-wrapper.tsx +++ b/apps/remix/app/components/dialogs/template-direct-link-dialog-wrapper.tsx @@ -1,14 +1,12 @@ -'use client'; +import { useState } from 'react'; -import React, { useState } from 'react'; - -import { Trans } from '@lingui/macro'; +import { Trans } from '@lingui/react/macro'; +import type { Recipient, Template, TemplateDirectLink } from '@prisma/client'; import { LinkIcon } from 'lucide-react'; -import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; -import { TemplateDirectLinkDialog } from '../template-direct-link-dialog'; +import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog'; export type TemplateDirectLinkDialogWrapperProps = { template: Template & { directLink?: TemplateDirectLink | null; recipients: Recipient[] }; diff --git a/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx b/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx similarity index 96% rename from apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx rename to apps/remix/app/components/dialogs/template-direct-link-dialog.tsx index f603f20be..8142eb848 100644 --- a/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx @@ -1,11 +1,16 @@ import { useEffect, useMemo, useState } from 'react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { + type Recipient, + RecipientRole, + type Template, + type TemplateDirectLink, +} from '@prisma/client'; import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react'; +import { Link, useRevalidator } from 'react-router'; import { P, match } from 'ts-pattern'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; @@ -14,12 +19,6 @@ import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template'; import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; -import { - type Recipient, - RecipientRole, - type Template, - type TemplateDirectLink, -} from '@documenso/prisma/client'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; @@ -65,9 +64,9 @@ export const TemplateDirectLinkDialog = ({ const { toast } = useToast(); const { quota, remaining } = useLimits(); const { _ } = useLingui(); + const { revalidate } = useRevalidator(); const [, copy] = useCopyToClipboard(); - const router = useRouter(); const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false); const [token, setToken] = useState(template.directLink?.token ?? null); @@ -77,7 +76,11 @@ export const TemplateDirectLinkDialog = ({ ); const validDirectTemplateRecipients = useMemo( - () => template.recipients.filter((recipient) => recipient.role !== RecipientRole.CC), + () => + template.recipients.filter( + (recipient) => + recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT, + ), [template.recipients], ); @@ -86,12 +89,12 @@ export const TemplateDirectLinkDialog = ({ isPending: isCreatingTemplateDirectLink, reset: resetCreateTemplateDirectLink, } = trpcReact.template.createTemplateDirectLink.useMutation({ - onSuccess: (data) => { + onSuccess: async (data) => { + await revalidate(); + setToken(data.token); setIsEnabled(data.enabled); setCurrentStep('MANAGE'); - - router.refresh(); }, onError: () => { setSelectedRecipientId(null); @@ -106,7 +109,9 @@ export const TemplateDirectLinkDialog = ({ const { mutateAsync: toggleTemplateDirectLink, isPending: isTogglingTemplateAccess } = trpcReact.template.toggleTemplateDirectLink.useMutation({ - onSuccess: (data) => { + onSuccess: async (data) => { + await revalidate(); + const enabledDescription = msg`Direct link signing has been enabled`; const disabledDescription = msg`Direct link signing has been disabled`; @@ -129,7 +134,9 @@ export const TemplateDirectLinkDialog = ({ const { mutateAsync: deleteTemplateDirectLink, isPending: isDeletingTemplateDirectLink } = trpcReact.template.deleteTemplateDirectLink.useMutation({ - onSuccess: () => { + onSuccess: async () => { + await revalidate(); + onOpenChange(false); setToken(null); @@ -139,7 +146,6 @@ export const TemplateDirectLinkDialog = ({ duration: 5000, }); - router.refresh(); setToken(null); }, onError: () => { @@ -231,7 +237,7 @@ export const TemplateDirectLinkDialog = ({ templates.{' '} Upgrade your account to continue! @@ -432,7 +438,7 @@ export const TemplateDirectLinkDialog = ({ await toggleTemplateDirectLink({ templateId: template.id, enabled: isEnabled, - }).catch((e) => null); + }).catch(() => null); onOpenChange(false); }} diff --git a/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx b/apps/remix/app/components/dialogs/template-duplicate-dialog.tsx similarity index 88% rename from apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx rename to apps/remix/app/components/dialogs/template-duplicate-dialog.tsx index 34beee309..1e999129f 100644 --- a/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-duplicate-dialog.tsx @@ -1,7 +1,6 @@ -import { useRouter } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; @@ -15,28 +14,23 @@ import { } from '@documenso/ui/primitives/dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; -type DuplicateTemplateDialogProps = { +type TemplateDuplicateDialogProps = { id: number; - teamId?: number; open: boolean; onOpenChange: (_open: boolean) => void; }; -export const DuplicateTemplateDialog = ({ +export const TemplateDuplicateDialog = ({ id, open, onOpenChange, -}: DuplicateTemplateDialogProps) => { - const router = useRouter(); - +}: TemplateDuplicateDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); const { mutateAsync: duplicateTemplate, isPending } = trpcReact.template.duplicateTemplate.useMutation({ onSuccess: () => { - router.refresh(); - toast({ title: _(msg`Template duplicated`), description: _(msg`Your template has been duplicated successfully.`), diff --git a/apps/web/src/app/(dashboard)/templates/move-template-dialog.tsx b/apps/remix/app/components/dialogs/template-move-dialog.tsx similarity index 81% rename from apps/web/src/app/(dashboard)/templates/move-template-dialog.tsx rename to apps/remix/app/components/dialogs/template-move-dialog.tsx index 9a00b9d5b..f113317eb 100644 --- a/apps/web/src/app/(dashboard)/templates/move-template-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-move-dialog.tsx @@ -1,13 +1,12 @@ import { useState } from 'react'; -import { useRouter } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { match } from 'ts-pattern'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { trpc } from '@documenso/trpc/react'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; import { Button } from '@documenso/ui/primitives/button'; @@ -28,29 +27,46 @@ import { } from '@documenso/ui/primitives/select'; import { useToast } from '@documenso/ui/primitives/use-toast'; -type MoveTemplateDialogProps = { +type TemplateMoveDialogProps = { templateId: number; open: boolean; onOpenChange: (_open: boolean) => void; + onMove?: ({ + templateId, + teamUrl, + }: { + templateId: number; + teamUrl: string; + }) => Promise | void; }; -export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTemplateDialogProps) => { - const router = useRouter(); - +export const TemplateMoveDialog = ({ + templateId, + open, + onOpenChange, + onMove, +}: TemplateMoveDialogProps) => { const { toast } = useToast(); const { _ } = useLingui(); const [selectedTeamId, setSelectedTeamId] = useState(null); const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery(); + const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({ - onSuccess: () => { - router.refresh(); + onSuccess: async () => { + const team = teams?.find((team) => team.id === selectedTeamId); + + if (team) { + await onMove?.({ templateId, teamUrl: team.url }); + } + toast({ title: _(msg`Template moved`), description: _(msg`The template has been successfully moved to the selected team.`), duration: 5000, }); + onOpenChange(false); }, onError: (err) => { @@ -73,7 +89,7 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl }, }); - const onMove = async () => { + const handleOnMove = async () => { if (!selectedTeamId) { return; } @@ -108,9 +124,7 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
{team.avatarImageId && ( - + )} @@ -130,7 +144,11 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl - diff --git a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx b/apps/remix/app/components/dialogs/template-use-dialog.tsx similarity index 98% rename from apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx rename to apps/remix/app/components/dialogs/template-use-dialog.tsx index 6c7508327..2f8266ef1 100644 --- a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-use-dialog.tsx @@ -1,14 +1,14 @@ -'use client'; - import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { Recipient } from '@prisma/client'; +import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client'; import { InfoIcon, Plus, Upload, X } from 'lucide-react'; import { useFieldArray, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; import * as z from 'zod'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; @@ -18,8 +18,6 @@ import { } from '@documenso/lib/constants/template'; import { AppError } from '@documenso/lib/errors/app-error'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; -import type { Recipient } from '@documenso/prisma/client'; -import { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -94,7 +92,7 @@ const ZAddRecipientsForNewDocumentSchema = z type TAddRecipientsForNewDocumentSchema = z.infer; -export type UseTemplateDialogProps = { +export type TemplateUseDialogProps = { templateId: number; templateSigningOrder?: DocumentSigningOrder | null; recipients: Recipient[]; @@ -103,19 +101,19 @@ export type UseTemplateDialogProps = { trigger?: React.ReactNode; }; -export function UseTemplateDialog({ +export function TemplateUseDialog({ recipients, documentDistributionMethod = DocumentDistributionMethod.EMAIL, documentRootPath, templateId, templateSigningOrder, trigger, -}: UseTemplateDialogProps) { - const router = useRouter(); - +}: TemplateUseDialogProps) { const { toast } = useToast(); const { _ } = useLingui(); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); const form = useForm({ @@ -179,7 +177,7 @@ export function UseTemplateDialog({ documentPath += '?action=view-signing-links'; } - router.push(documentPath); + await navigate(documentPath); } catch (err) { const error = AppError.parseError(err); diff --git a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx b/apps/remix/app/components/dialogs/token-delete-dialog.tsx similarity index 88% rename from apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx rename to apps/remix/app/components/dialogs/token-delete-dialog.tsx index adaac05b0..511ce04db 100644 --- a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx +++ b/apps/remix/app/components/dialogs/token-delete-dialog.tsx @@ -1,16 +1,13 @@ -'use client'; - import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { ApiToken } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import type { ApiToken } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -33,35 +30,31 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type DeleteTokenDialogProps = { - teamId?: number; +import { useOptionalCurrentTeam } from '~/providers/team'; + +export type TokenDeleteDialogProps = { token: Pick; onDelete?: () => void; children?: React.ReactNode; }; -export default function DeleteTokenDialog({ - teamId, - token, - onDelete, - children, -}: DeleteTokenDialogProps) { +export default function TokenDeleteDialog({ token, onDelete, children }: TokenDeleteDialogProps) { const { _ } = useLingui(); const { toast } = useToast(); - const router = useRouter(); + const team = useOptionalCurrentTeam(); const [isOpen, setIsOpen] = useState(false); const deleteMessage = _(msg`delete ${token.name}`); - const ZDeleteTokenDialogSchema = z.object({ + const ZTokenDeleteDialogSchema = z.object({ tokenName: z.literal(deleteMessage, { errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }), }), }); - type TDeleteTokenByIdMutationSchema = z.infer; + type TDeleteTokenByIdMutationSchema = z.infer; const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({ onSuccess() { @@ -70,7 +63,7 @@ export default function DeleteTokenDialog({ }); const form = useForm({ - resolver: zodResolver(ZDeleteTokenDialogSchema), + resolver: zodResolver(ZTokenDeleteDialogSchema), values: { tokenName: '', }, @@ -80,7 +73,7 @@ export default function DeleteTokenDialog({ try { await deleteTokenMutation({ id: token.id, - teamId, + teamId: team?.id, }); toast({ @@ -90,8 +83,6 @@ export default function DeleteTokenDialog({ }); setIsOpen(false); - - router.refresh(); } catch (error) { toast({ title: _(msg`An unknown error occurred`), diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx similarity index 89% rename from apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx rename to apps/remix/app/components/dialogs/webhook-create-dialog.tsx index 0d3afd52e..f8c5c94d2 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx @@ -1,12 +1,9 @@ -'use client'; - import { useState } from 'react'; -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; import { useForm } from 'react-hook-form'; import type { z } from 'zod'; @@ -39,22 +36,20 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { useOptionalCurrentTeam } from '~/providers/team'; -import { TriggerMultiSelectCombobox } from './trigger-multiselect-combobox'; +import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox'; const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true }); type TCreateWebhookFormSchema = z.infer; -export type CreateWebhookDialogProps = { +export type WebhookCreateDialogProps = { trigger?: React.ReactNode; } & Omit; -export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => { +export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); - const router = useRouter(); - const team = useOptionalCurrentTeam(); const [open, setOpen] = useState(false); @@ -94,8 +89,6 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr }); form.reset(); - - router.refresh(); } catch (err) { toast({ title: _(msg`Error`), @@ -191,7 +184,7 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr Triggers - { onChange(values); @@ -237,14 +230,13 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr /> -
- - -
+ + +
diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx similarity index 93% rename from apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx rename to apps/remix/app/components/dialogs/webhook-delete-dialog.tsx index 62d9df9bc..5842f4fb7 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx @@ -1,16 +1,13 @@ -'use effect'; - import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { Webhook } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import type { Webhook } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -35,18 +32,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { useOptionalCurrentTeam } from '~/providers/team'; -export type DeleteWebhookDialogProps = { +export type WebhookDeleteDialogProps = { webhook: Pick; onDelete?: () => void; children: React.ReactNode; }; -export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => { +export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); - const router = useRouter(); - const team = useOptionalCurrentTeam(); const [open, setOpen] = useState(false); @@ -81,8 +76,6 @@ export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogPr }); setOpen(false); - - router.refresh(); } catch (error) { toast({ title: _(msg`An unknown error occurred`), diff --git a/apps/remix/app/components/embed/embed-authentication-required.tsx b/apps/remix/app/components/embed/embed-authentication-required.tsx new file mode 100644 index 000000000..db65e7a2f --- /dev/null +++ b/apps/remix/app/components/embed/embed-authentication-required.tsx @@ -0,0 +1,49 @@ +import { Trans } from '@lingui/react/macro'; + +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; + +import { SignInForm } from '~/components/forms/signin'; +import { BrandingLogo } from '~/components/general/branding-logo'; + +export type EmbedAuthenticationRequiredProps = { + email?: string; + returnTo: string; + isGoogleSSOEnabled?: boolean; + isOIDCSSOEnabled?: boolean; + oidcProviderLabel?: string; +}; + +export const EmbedAuthenticationRequired = ({ + email, + returnTo, + // isGoogleSSOEnabled, + // isOIDCSSOEnabled, + // oidcProviderLabel, +}: EmbedAuthenticationRequiredProps) => { + return ( +
+
+ + + + + + To view this document you need to be signed into your account, please sign in to + continue. + + + + + +
+
+ ); +}; diff --git a/apps/web/src/app/embed/client-loading.tsx b/apps/remix/app/components/embed/embed-client-loading.tsx similarity index 100% rename from apps/web/src/app/embed/client-loading.tsx rename to apps/remix/app/components/embed/embed-client-loading.tsx diff --git a/apps/web/src/app/embed/direct/[[...url]]/client.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx similarity index 78% rename from apps/web/src/app/embed/direct/[[...url]]/client.tsx rename to apps/remix/app/components/embed/embed-direct-template-client-page.tsx index 266672209..c88813e1f 100644 --- a/apps/web/src/app/embed/direct/[[...url]]/client.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -1,21 +1,23 @@ -'use client'; - import { useEffect, useLayoutEffect, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { type DocumentData, type Field, FieldType } from '@prisma/client'; +import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { DateTime } from 'luxon'; +import { useSearchParams } from 'react-router'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import { + isFieldUnsignedAndRequired, + isRequiredField, +} from '@documenso/lib/utils/advanced-fields-helpers'; import { validateFieldsInserted } from '@documenso/lib/utils/fields'; -import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@documenso/prisma/client'; -import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import type { TRemovedSignedFieldWithTokenMutationSchema, @@ -27,19 +29,19 @@ import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; -import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import type { DirectTemplateLocalField } from '~/app/(recipient)/d/[token]/sign-direct-template'; -import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; -import { Logo } from '~/components/branding/logo'; +import { BrandingLogo } from '~/components/general/branding-logo'; +import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema'; +import { injectCss } from '~/utils/css-vars'; -import { EmbedClientLoading } from '../../client-loading'; -import { EmbedDocumentCompleted } from '../../completed'; -import { EmbedDocumentFields } from '../../document-fields'; -import { injectCss } from '../../util'; -import { ZDirectTemplateEmbedDataSchema } from './schema'; +import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form'; +import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; +import { EmbedClientLoading } from './embed-client-loading'; +import { EmbedDocumentCompleted } from './embed-document-completed'; +import { EmbedDocumentFields } from './embed-document-fields'; export type EmbedDirectTemplateClientPageProps = { token: string; @@ -49,7 +51,7 @@ export type EmbedDirectTemplateClientPageProps = { fields: Field[]; metadata?: DocumentMeta | TemplateMeta | null; hidePoweredBy?: boolean; - isPlatformOrEnterprise?: boolean; + allowWhiteLabelling?: boolean; }; export const EmbedDirectTemplateClientPage = ({ @@ -60,12 +62,12 @@ export const EmbedDirectTemplateClientPage = ({ fields, metadata, hidePoweredBy = false, - isPlatformOrEnterprise = false, + allowWhiteLabelling = false, }: EmbedDirectTemplateClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); - const searchParams = useSearchParams(); + const [searchParams] = useSearchParams(); const { fullName, @@ -76,7 +78,7 @@ export const EmbedDirectTemplateClientPage = ({ setEmail, setSignature, setSignatureValid, - } = useRequiredSigningContext(); + } = useRequiredDocumentSigningContext(); const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); @@ -94,7 +96,7 @@ export const EmbedDirectTemplateClientPage = ({ const [localFields, setLocalFields] = useState(() => fields); const [pendingFields, _completedFields] = [ - localFields.filter((field) => !field.inserted), + localFields.filter((field) => isFieldUnsignedAndRequired(field)), localFields.filter((field) => field.inserted), ]; @@ -112,7 +114,7 @@ export const EmbedDirectTemplateClientPage = ({ const newField: DirectTemplateLocalField = structuredClone({ ...field, - customText: payload.value, + customText: payload.value ?? '', inserted: true, signedValue: payload, }); @@ -123,8 +125,10 @@ export const EmbedDirectTemplateClientPage = ({ created: new Date(), recipientId: 1, fieldId: 1, - signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null, - typedSignature: payload.value.startsWith('data:') ? null : payload.value, + signatureImageAsBase64: + payload.value && payload.value.startsWith('data:') ? payload.value : null, + typedSignature: + payload.value && !payload.value.startsWith('data:') ? payload.value : null, } satisfies Signature; } @@ -182,7 +186,7 @@ export const EmbedDirectTemplateClientPage = ({ }; const onNextFieldClick = () => { - validateFieldsInserted(localFields); + validateFieldsInserted(pendingFields); setShowPendingFieldTooltip(true); setIsExpanded(false); @@ -194,7 +198,7 @@ export const EmbedDirectTemplateClientPage = ({ return; } - const valid = validateFieldsInserted(localFields); + const valid = validateFieldsInserted(pendingFields); if (!valid) { setShowPendingFieldTooltip(true); @@ -207,12 +211,6 @@ export const EmbedDirectTemplateClientPage = ({ directTemplateExternalId = decodeURIComponent(directTemplateExternalId); } - localFields.forEach((field) => { - if (!field.signedValue) { - throw new Error('Invalid configuration'); - } - }); - const { documentId, token: documentToken, @@ -223,13 +221,11 @@ export const EmbedDirectTemplateClientPage = ({ directRecipientName: fullName, directRecipientEmail: email, templateUpdatedAt: updatedAt, - signedFieldValues: localFields.map((field) => { - if (!field.signedValue) { - throw new Error('Invalid configuration'); - } - - return field.signedValue; - }), + signedFieldValues: localFields + .filter((field) => { + return field.signedValue && (isRequiredField(field) || field.inserted); + }) + .map((field) => field.signedValue!), }); if (window.parent) { @@ -288,7 +284,7 @@ export const EmbedDirectTemplateClientPage = ({ document.documentElement.classList.add('dark-mode-disabled'); } - if (isPlatformOrEnterprise) { + if (allowWhiteLabelling) { injectCss({ css: data.css, cssVars: data.cssVars, @@ -340,7 +336,7 @@ export const EmbedDirectTemplateClientPage = ({
{/* Viewer */}
- setHasDocumentLoaded(true)} /> @@ -349,7 +345,7 @@ export const EmbedDirectTemplateClientPage = ({ {/* Widget */}
@@ -417,40 +413,42 @@ export const EmbedDirectTemplateClientPage = ({ />
-
- + {hasSignatureField && ( +
+ - - - { - setSignature(value); - }} - onValidityChange={(isValid) => { - setSignatureValid(isValid); - }} - allowTypedSignature={Boolean( - metadata && - 'typedSignatureEnabled' in metadata && - metadata.typedSignatureEnabled, - )} - /> - - + + + { + setSignature(value); + }} + onValidityChange={(isValid) => { + setSignatureValid(isValid); + }} + allowTypedSignature={Boolean( + metadata && + 'typedSignatureEnabled' in metadata && + metadata.typedSignatureEnabled, + )} + /> + + - {hasSignatureField && !signatureValid && ( -
- - Signature is too small. Please provide a more complete signature. - -
- )} -
+ {hasSignatureField && !signatureValid && ( +
+ + Signature is too small. Please provide a more complete signature. + +
+ )} +
+ )}
@@ -485,7 +483,6 @@ export const EmbedDirectTemplateClientPage = ({ {/* Fields */} Powered by - +
)}
diff --git a/apps/web/src/app/embed/completed.tsx b/apps/remix/app/components/embed/embed-document-completed.tsx similarity index 81% rename from apps/web/src/app/embed/completed.tsx rename to apps/remix/app/components/embed/embed-document-completed.tsx index 1cfc07d3b..f334ce644 100644 --- a/apps/web/src/app/embed/completed.tsx +++ b/apps/remix/app/components/embed/embed-document-completed.tsx @@ -1,7 +1,7 @@ -import { Trans } from '@lingui/macro'; +import { Trans } from '@lingui/react/macro'; +import type { Signature } from '@prisma/client'; import signingCelebration from '@documenso/assets/images/signing-celebration.png'; -import type { Signature } from '@documenso/prisma/client'; import { SigningCard3D } from '@documenso/ui/components/signing-card'; export type EmbedDocumentCompletedPageProps = { @@ -12,7 +12,7 @@ export type EmbedDocumentCompletedPageProps = { export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => { console.log({ signature }); return ( -
+

Document Completed!

diff --git a/apps/web/src/app/embed/document-fields.tsx b/apps/remix/app/components/embed/embed-document-fields.tsx similarity index 74% rename from apps/web/src/app/embed/document-fields.tsx rename to apps/remix/app/components/embed/embed-document-fields.tsx index 79256b07e..99c8e4600 100644 --- a/apps/web/src/app/embed/document-fields.tsx +++ b/apps/remix/app/components/embed/embed-document-fields.tsx @@ -1,5 +1,5 @@ -'use client'; - +import type { DocumentMeta, TemplateMeta } from '@prisma/client'; +import { type Field, FieldType } from '@prisma/client'; import { match } from 'ts-pattern'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; @@ -12,8 +12,6 @@ import { ZRadioFieldMeta, ZTextFieldMeta, } from '@documenso/lib/types/field-meta'; -import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client'; -import { type Field, FieldType } from '@documenso/prisma/client'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import type { TRemovedSignedFieldWithTokenMutationSchema, @@ -21,19 +19,18 @@ import type { } from '@documenso/trpc/server/field-router/schema'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; -import { CheckboxField } from '~/app/(signing)/sign/[token]/checkbox-field'; -import { DateField } from '~/app/(signing)/sign/[token]/date-field'; -import { DropdownField } from '~/app/(signing)/sign/[token]/dropdown-field'; -import { EmailField } from '~/app/(signing)/sign/[token]/email-field'; -import { InitialsField } from '~/app/(signing)/sign/[token]/initials-field'; -import { NameField } from '~/app/(signing)/sign/[token]/name-field'; -import { NumberField } from '~/app/(signing)/sign/[token]/number-field'; -import { RadioField } from '~/app/(signing)/sign/[token]/radio-field'; -import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field'; -import { TextField } from '~/app/(signing)/sign/[token]/text-field'; +import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; +import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field'; +import { DocumentSigningDropdownField } from '~/components/general/document-signing/document-signing-dropdown-field'; +import { DocumentSigningEmailField } from '~/components/general/document-signing/document-signing-email-field'; +import { DocumentSigningInitialsField } from '~/components/general/document-signing/document-signing-initials-field'; +import { DocumentSigningNameField } from '~/components/general/document-signing/document-signing-name-field'; +import { DocumentSigningNumberField } from '~/components/general/document-signing/document-signing-number-field'; +import { DocumentSigningRadioField } from '~/components/general/document-signing/document-signing-radio-field'; +import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field'; +import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; export type EmbedDocumentFieldsProps = { - recipient: Recipient; fields: Field[]; metadata?: DocumentMeta | TemplateMeta | null; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; @@ -41,7 +38,6 @@ export type EmbedDocumentFieldsProps = { }; export const EmbedDocumentFields = ({ - recipient, fields, metadata, onSignField, @@ -52,38 +48,34 @@ export const EmbedDocumentFields = ({ {fields.map((field) => match(field.type) .with(FieldType.SIGNATURE, () => ( - )) .with(FieldType.INITIALS, () => ( - )) .with(FieldType.NAME, () => ( - )) .with(FieldType.DATE, () => ( - )) .with(FieldType.EMAIL, () => ( - @@ -106,10 +97,9 @@ export const EmbedDocumentFields = ({ }; return ( - @@ -122,10 +112,9 @@ export const EmbedDocumentFields = ({ }; return ( - @@ -138,10 +127,9 @@ export const EmbedDocumentFields = ({ }; return ( - @@ -154,10 +142,9 @@ export const EmbedDocumentFields = ({ }; return ( - @@ -170,10 +157,9 @@ export const EmbedDocumentFields = ({ }; return ( - diff --git a/apps/remix/app/components/embed/embed-document-rejected.tsx b/apps/remix/app/components/embed/embed-document-rejected.tsx new file mode 100644 index 000000000..911df8729 --- /dev/null +++ b/apps/remix/app/components/embed/embed-document-rejected.tsx @@ -0,0 +1,33 @@ +import { Trans } from '@lingui/react/macro'; +import { XCircle } from 'lucide-react'; + +export const EmbedDocumentRejected = () => { + return ( +
+
+
+ + +

+ Document Rejected +

+
+ +
+ You have rejected this document +
+ +

+ + The document owner has been notified of your decision. They may contact you with further + instructions if necessary. + +

+ +

+ No further action is required from you at this time. +

+
+
+ ); +}; diff --git a/apps/remix/app/components/embed/embed-document-signing-page.tsx b/apps/remix/app/components/embed/embed-document-signing-page.tsx new file mode 100644 index 000000000..5361ded5e --- /dev/null +++ b/apps/remix/app/components/embed/embed-document-signing-page.tsx @@ -0,0 +1,514 @@ +import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { DocumentMeta, TemplateMeta } from '@prisma/client'; +import { + type DocumentData, + type Field, + FieldType, + RecipientRole, + SigningStatus, +} from '@prisma/client'; +import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; + +import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; +import { validateFieldsInserted } from '@documenso/lib/utils/fields'; +import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; +import { trpc } from '@documenso/trpc/react'; +import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { ElementVisible } from '@documenso/ui/primitives/element-visible'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; +import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; +import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { BrandingLogo } from '~/components/general/branding-logo'; +import { injectCss } from '~/utils/css-vars'; + +import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema'; +import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; +import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider'; +import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog'; +import { EmbedClientLoading } from './embed-client-loading'; +import { EmbedDocumentCompleted } from './embed-document-completed'; +import { EmbedDocumentFields } from './embed-document-fields'; +import { EmbedDocumentRejected } from './embed-document-rejected'; + +export type EmbedSignDocumentClientPageProps = { + token: string; + documentId: number; + documentData: DocumentData; + recipient: RecipientWithFields; + fields: Field[]; + metadata?: DocumentMeta | TemplateMeta | null; + isCompleted?: boolean; + hidePoweredBy?: boolean; + allowWhitelabelling?: boolean; + allRecipients?: RecipientWithFields[]; +}; + +export const EmbedSignDocumentClientPage = ({ + token, + documentId, + documentData, + recipient, + fields, + metadata, + isCompleted, + hidePoweredBy = false, + allowWhitelabelling = false, + allRecipients = [], +}: EmbedSignDocumentClientPageProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const { + fullName, + email, + signature, + signatureValid, + setFullName, + setSignature, + setSignatureValid, + } = useRequiredDocumentSigningContext(); + + const [hasFinishedInit, setHasFinishedInit] = useState(false); + const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); + const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted); + const [hasRejectedDocument, setHasRejectedDocument] = useState( + recipient.signingStatus === SigningStatus.REJECTED, + ); + const [selectedSignerId, setSelectedSignerId] = useState( + allRecipients.length > 0 ? allRecipients[0].id : null, + ); + + const [isExpanded, setIsExpanded] = useState(false); + const [isNameLocked, setIsNameLocked] = useState(false); + const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); + + const [allowDocumentRejection, setAllowDocumentRejection] = useState(false); + + const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId); + const isAssistantMode = recipient.role === RecipientRole.ASSISTANT; + + const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500); + + const [pendingFields, _completedFields] = [ + fields.filter( + (field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field), + ), + fields.filter((field) => field.inserted), + ]; + + const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } = + trpc.recipient.completeDocumentWithToken.useMutation(); + + const fieldsRequiringValidation = useMemo( + () => fields.filter(isFieldUnsignedAndRequired), + [fields], + ); + + const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); + + const assistantSignersId = useId(); + + const onNextFieldClick = () => { + validateFieldsInserted(fieldsRequiringValidation); + + setShowPendingFieldTooltip(true); + setIsExpanded(false); + }; + + const onCompleteClick = async () => { + try { + if (hasSignatureField && !signatureValid) { + return; + } + + const valid = validateFieldsInserted(fieldsRequiringValidation); + + if (!valid) { + setShowPendingFieldTooltip(true); + return; + } + + await completeDocumentWithToken({ + documentId, + token, + }); + + if (window.parent) { + window.parent.postMessage( + { + action: 'document-completed', + data: { + token, + documentId, + recipientId: recipient.id, + }, + }, + '*', + ); + } + + setHasCompletedDocument(true); + } catch (err) { + if (window.parent) { + window.parent.postMessage( + { + action: 'document-error', + data: null, + }, + '*', + ); + } + + toast({ + title: _(msg`Something went wrong`), + description: _( + msg`We were unable to submit this document at this time. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + const onDocumentRejected = (reason: string) => { + if (window.parent) { + window.parent.postMessage( + { + action: 'document-rejected', + data: { + token, + documentId, + recipientId: recipient.id, + reason, + }, + }, + '*', + ); + } + + setHasRejectedDocument(true); + }; + + useLayoutEffect(() => { + const hash = window.location.hash.slice(1); + + try { + const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash)))); + + if (!isCompleted && data.name) { + setFullName(data.name); + } + + // Since a recipient can be provided a name we can lock it without requiring + // a to be provided by the parent application, unlike direct templates. + setIsNameLocked(!!data.lockName); + setAllowDocumentRejection(!!data.allowDocumentRejection); + + if (data.darkModeDisabled) { + document.documentElement.classList.add('dark-mode-disabled'); + } + + if (allowWhitelabelling) { + injectCss({ + css: data.css, + cssVars: data.cssVars, + }); + } + } catch (err) { + console.error(err); + } + + setHasFinishedInit(true); + + // !: While the two setters are stable we still want to ensure we're avoiding + // !: re-renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (hasFinishedInit && hasDocumentLoaded && window.parent) { + window.parent.postMessage( + { + action: 'document-ready', + data: null, + }, + '*', + ); + } + }, [hasFinishedInit, hasDocumentLoaded]); + + if (hasRejectedDocument) { + return ; + } + + if (hasCompletedDocument) { + return ( + + ); + } + + return ( + +
+ {(!hasFinishedInit || !hasDocumentLoaded) && } + + {allowDocumentRejection && ( +
+ +
+ )} + +
+ {/* Viewer */} +
+ setHasDocumentLoaded(true)} + /> +
+ + {/* Widget */} +
+
+ {/* Header */} +
+
+

+ {isAssistantMode ? ( + Assist with signing + ) : ( + Sign document + )} +

+ + +
+
+ +
+

+ {isAssistantMode ? ( + Help complete the document for other signers. + ) : ( + Sign the document to complete the process. + )} +

+ +
+
+ + {/* Form */} +
+
+ {isAssistantMode && ( +
+ + +
+ setSelectedSignerId(Number(value))} + > + {allRecipients + .filter((r) => r.fields.length > 0) + .map((r) => ( +
+
+
+ + +
+ +

{r.email}

+
+
+
+ {r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'} +
+
+
+ ))} +
+
+
+ )} + + {!isAssistantMode && ( + <> +
+ + + !isNameLocked && setFullName(e.target.value)} + /> +
+ +
+ + + +
+ + {hasSignatureField && ( +
+ + + + + { + setSignature(value); + }} + onValidityChange={(isValid) => { + setSignatureValid(isValid); + }} + allowTypedSignature={Boolean( + metadata && + 'typedSignatureEnabled' in metadata && + metadata.typedSignatureEnabled, + )} + /> + + + + {hasSignatureField && !signatureValid && ( +
+ + Signature is too small. Please provide a more complete signature. + +
+ )} +
+ )} + + )} +
+
+ +
+ +
+ {pendingFields.length > 0 ? ( + + ) : ( + + )} +
+
+
+ + + {showPendingFieldTooltip && pendingFields.length > 0 && ( + + Click to insert field + + )} + + + {/* Fields */} + +
+ + {!hidePoweredBy && ( +
+ Powered by + +
+ )} +
+ + ); +}; diff --git a/apps/remix/app/components/embed/embed-document-waiting-for-turn.tsx b/apps/remix/app/components/embed/embed-document-waiting-for-turn.tsx new file mode 100644 index 000000000..a0c82cfc2 --- /dev/null +++ b/apps/remix/app/components/embed/embed-document-waiting-for-turn.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; + +import { Trans } from '@lingui/react/macro'; + +export const EmbedDocumentWaitingForTurn = () => { + const [hasPostedMessage, setHasPostedMessage] = useState(false); + + useEffect(() => { + if (window.parent && !hasPostedMessage) { + window.parent.postMessage( + { + action: 'document-waiting-for-turn', + data: null, + }, + '*', + ); + } + + setHasPostedMessage(true); + }, [hasPostedMessage]); + + if (!hasPostedMessage) { + return null; + } + + return ( +
+

+ Waiting for Your Turn +

+ +
+

+ + It's currently not your turn to sign. Please check back soon as this document should be + available for you to sign shortly. + +

+ +

+ Please check with the parent application for more information. +

+
+
+ ); +}; diff --git a/apps/web/src/app/embed/paywall.tsx b/apps/remix/app/components/embed/embed-paywall.tsx similarity index 100% rename from apps/web/src/app/embed/paywall.tsx rename to apps/remix/app/components/embed/embed-paywall.tsx diff --git a/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx b/apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx similarity index 94% rename from apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx rename to apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx index 5078a87a0..ac1b03dd3 100644 --- a/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx +++ b/apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx @@ -1,17 +1,15 @@ -'use client'; - import { useState } from 'react'; -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { flushSync } from 'react-dom'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { trpc } from '@documenso/trpc/react'; +import { authClient } from '@documenso/auth/client'; +import { useSession } from '@documenso/lib/client-only/providers/session'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -42,16 +40,13 @@ export const ZDisable2FAForm = z.object({ export type TDisable2FAForm = z.infer; export const DisableAuthenticatorAppDialog = () => { - const router = useRouter(); - const { _ } = useLingui(); const { toast } = useToast(); + const { refreshSession } = useSession(); const [isOpen, setIsOpen] = useState(false); const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp'); - const { mutateAsync: disable2FA } = trpc.twoFactorAuthentication.disable.useMutation(); - const disable2FAForm = useForm({ defaultValues: { totpCode: '', @@ -84,7 +79,7 @@ export const DisableAuthenticatorAppDialog = () => { const onDisable2FAFormSubmit = async ({ totpCode, backupCode }: TDisable2FAForm) => { try { - await disable2FA({ totpCode, backupCode }); + await authClient.twoFactor.disable({ totpCode, backupCode }); toast({ title: _(msg`Two-factor authentication disabled`), @@ -97,7 +92,7 @@ export const DisableAuthenticatorAppDialog = () => { onCloseTwoFactorDisableDialog(); }); - router.refresh(); + await refreshSession(); } catch (_err) { toast({ title: _(msg`Unable to disable two-factor authentication`), diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/remix/app/components/forms/2fa/enable-authenticator-app-dialog.tsx similarity index 90% rename from apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx rename to apps/remix/app/components/forms/2fa/enable-authenticator-app-dialog.tsx index 5965db3d8..d0a23aa32 100644 --- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx +++ b/apps/remix/app/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -1,18 +1,16 @@ -'use client'; - import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { useForm } from 'react-hook-form'; import { renderSVG } from 'uqr'; import { z } from 'zod'; +import { authClient } from '@documenso/auth/client'; import { downloadFile } from '@documenso/lib/client-only/download-file'; -import { trpc } from '@documenso/trpc/react'; +import { useSession } from '@documenso/lib/client-only/providers/session'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -50,29 +48,12 @@ export type EnableAuthenticatorAppDialogProps = { export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); - - const router = useRouter(); + const { refreshSession } = useSession(); const [isOpen, setIsOpen] = useState(false); const [recoveryCodes, setRecoveryCodes] = useState(null); - - const { mutateAsync: enable2FA } = trpc.twoFactorAuthentication.enable.useMutation(); - - const { - mutateAsync: setup2FA, - data: setup2FAData, - isPending: isSettingUp2FA, - } = trpc.twoFactorAuthentication.setup.useMutation({ - onError: () => { - toast({ - title: _(msg`Unable to setup two-factor authentication`), - description: _( - msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`, - ), - variant: 'destructive', - }); - }, - }); + const [isSettingUp2FA, setIsSettingUp2FA] = useState(false); + const [setup2FAData, setSetup2FAData] = useState<{ uri: string; secret: string } | null>(null); const enable2FAForm = useForm({ defaultValues: { @@ -83,9 +64,36 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA const { isSubmitting: isEnabling2FA } = enable2FAForm.formState; + const setup2FA = async () => { + if (isSettingUp2FA) { + return; + } + + setIsSettingUp2FA(true); + setSetup2FAData(null); + + try { + const data = await authClient.twoFactor.setup(); + await refreshSession(); + + setSetup2FAData(data); + } catch (err) { + toast({ + title: _(msg`Unable to setup two-factor authentication`), + description: _( + msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`, + ), + variant: 'destructive', + }); + } + + setIsSettingUp2FA(false); + }; + const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => { try { - const data = await enable2FA({ code: token }); + const data = await authClient.twoFactor.enable({ code: token }); + await refreshSession(); setRecoveryCodes(data.recoveryCodes); onSuccess?.(); @@ -133,7 +141,6 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA if (!isOpen && recoveryCodes && recoveryCodes.length > 0) { setRecoveryCodes(null); - router.refresh(); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/web/src/components/forms/2fa/recovery-code-list.tsx b/apps/remix/app/components/forms/2fa/recovery-code-list.tsx similarity index 97% rename from apps/web/src/components/forms/2fa/recovery-code-list.tsx rename to apps/remix/app/components/forms/2fa/recovery-code-list.tsx index 2b72883f2..d45f71227 100644 --- a/apps/web/src/components/forms/2fa/recovery-code-list.tsx +++ b/apps/remix/app/components/forms/2fa/recovery-code-list.tsx @@ -1,4 +1,4 @@ -import { msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Copy } from 'lucide-react'; diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/remix/app/components/forms/2fa/view-recovery-codes-dialog.tsx similarity index 84% rename from apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx rename to apps/remix/app/components/forms/2fa/view-recovery-codes-dialog.tsx index 588468c75..a1085dbf3 100644 --- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx +++ b/apps/remix/app/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -1,17 +1,14 @@ -'use client'; - import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans } from '@lingui/macro'; +import { Trans } from '@lingui/react/macro'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; import { z } from 'zod'; +import { authClient } from '@documenso/auth/client'; import { downloadFile } from '@documenso/lib/client-only/download-file'; import { AppError } from '@documenso/lib/errors/app-error'; -import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; -import { trpc } from '@documenso/trpc/react'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -44,20 +41,32 @@ export type TViewRecoveryCodesForm = z.infer; export const ViewRecoveryCodesDialog = () => { const [isOpen, setIsOpen] = useState(false); - const { - data: recoveryCodes, - mutate, - isPending, - error, - } = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); + const [recoveryCodes, setRecoveryCodes] = useState(null); + const [error, setError] = useState(null); - const viewRecoveryCodesForm = useForm({ + const form = useForm({ defaultValues: { token: '', }, resolver: zodResolver(ZViewRecoveryCodesForm), }); + const onFormSubmit = async ({ token }: TViewRecoveryCodesForm) => { + setError(null); + + try { + const data = await authClient.twoFactor.viewRecoveryCodes({ + token, + }); + + setRecoveryCodes(data.backupCodes); + } catch (err) { + const error = AppError.parseError(err); + + setError(error.code); + } + }; + const downloadRecoveryCodes = () => { if (recoveryCodes) { const blob = new Blob([recoveryCodes.join('\n')], { @@ -109,8 +118,8 @@ export const ViewRecoveryCodesDialog = () => {
) : ( -
- mutate(value))}> + + View Recovery Codes @@ -121,10 +130,10 @@ export const ViewRecoveryCodesDialog = () => { -
+
( @@ -147,7 +156,7 @@ export const ViewRecoveryCodesDialog = () => { {match(AppError.parseError(error).message) - .with(ErrorCode.INCORRECT_TWO_FACTOR_CODE, () => ( + .with('INCORRECT_TWO_FACTOR_CODE', () => ( Invalid code. Please try again. )) .otherwise(() => ( @@ -164,7 +173,7 @@ export const ViewRecoveryCodesDialog = () => { - diff --git a/apps/web/src/components/forms/avatar-image.tsx b/apps/remix/app/components/forms/avatar-image.tsx similarity index 89% rename from apps/web/src/components/forms/avatar-image.tsx rename to apps/remix/app/components/forms/avatar-image.tsx index 64e6264c0..852063287 100644 --- a/apps/web/src/components/forms/avatar-image.tsx +++ b/apps/remix/app/components/forms/avatar-image.tsx @@ -1,22 +1,20 @@ -'use client'; - import { useMemo } from 'react'; -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { ErrorCode, useDropzone } from 'react-dropzone'; import { useForm } from 'react-hook-form'; +import { useRevalidator } from 'react-router'; import { match } from 'ts-pattern'; import { z } from 'zod'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { useSession } from '@documenso/lib/client-only/providers/session'; import { AppError } from '@documenso/lib/errors/app-error'; import { base64 } from '@documenso/lib/universal/base64'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; -import type { Team, User } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; @@ -31,6 +29,8 @@ import { } from '@documenso/ui/primitives/form/form'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { useOptionalCurrentTeam } from '~/providers/team'; + export const ZAvatarImageFormSchema = z.object({ bytes: z.string().nullish(), }); @@ -39,15 +39,15 @@ export type TAvatarImageFormSchema = z.infer; export type AvatarImageFormProps = { className?: string; - user: User; - team?: Team; }; -export const AvatarImageForm = ({ className, user, team }: AvatarImageFormProps) => { +export const AvatarImageForm = ({ className }: AvatarImageFormProps) => { + const { user, refreshSession } = useSession(); const { _ } = useLingui(); const { toast } = useToast(); + const { revalidate } = useRevalidator(); - const router = useRouter(); + const team = useOptionalCurrentTeam(); const { mutateAsync: setProfileImage } = trpc.profile.setProfileImage.useMutation(); @@ -103,13 +103,13 @@ export const AvatarImageForm = ({ className, user, team }: AvatarImageFormProps) teamId: team?.id, }); + await refreshSession(); + toast({ title: _(msg`Avatar Updated`), description: _(msg`Your avatar has been updated successfully.`), duration: 5000, }); - - router.refresh(); } catch (err) { const error = AppError.parseError(err); @@ -146,11 +146,7 @@ export const AvatarImageForm = ({ className, user, team }: AvatarImageFormProps)
- {avatarImageId && ( - - )} + {avatarImageId && } {initials} diff --git a/apps/web/src/components/forms/forgot-password.tsx b/apps/remix/app/components/forms/forgot-password.tsx similarity index 86% rename from apps/web/src/components/forms/forgot-password.tsx rename to apps/remix/app/components/forms/forgot-password.tsx index 446e12727..ffc9b356e 100644 --- a/apps/web/src/components/forms/forgot-password.tsx +++ b/apps/remix/app/components/forms/forgot-password.tsx @@ -1,14 +1,12 @@ -'use client'; - -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; import { z } from 'zod'; -import { trpc } from '@documenso/trpc/react'; +import { authClient } from '@documenso/auth/client'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -36,7 +34,7 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => { const { _ } = useLingui(); const { toast } = useToast(); - const router = useRouter(); + const navigate = useNavigate(); const form = useForm({ values: { @@ -47,10 +45,10 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => { const isSubmitting = form.formState.isSubmitting; - const { mutateAsync: forgotPassword } = trpc.profile.forgotPassword.useMutation(); - const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => { - await forgotPassword({ email }).catch(() => null); + await authClient.emailPassword.forgotPassword({ email }).catch(() => null); + + await navigate('/check-email'); toast({ title: _(msg`Reset email sent`), @@ -61,8 +59,6 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => { }); form.reset(); - - router.push('/check-email'); }; return ( diff --git a/apps/web/src/components/forms/password.tsx b/apps/remix/app/components/forms/password.tsx similarity index 93% rename from apps/web/src/components/forms/password.tsx rename to apps/remix/app/components/forms/password.tsx index c77373972..531645211 100644 --- a/apps/web/src/components/forms/password.tsx +++ b/apps/remix/app/components/forms/password.tsx @@ -1,15 +1,14 @@ -'use client'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; import { z } from 'zod'; +import { authClient } from '@documenso/auth/client'; +import type { SessionUser } from '@documenso/auth/server/lib/session/session'; import { AppError } from '@documenso/lib/errors/app-error'; -import type { User } from '@documenso/prisma/client'; -import { trpc } from '@documenso/trpc/react'; import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -39,7 +38,7 @@ export type TPasswordFormSchema = z.infer; export type PasswordFormProps = { className?: string; - user: User; + user: SessionUser; }; export const PasswordForm = ({ className }: PasswordFormProps) => { @@ -57,11 +56,9 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { const isSubmitting = form.formState.isSubmitting; - const { mutateAsync: updatePassword } = trpc.profile.updatePassword.useMutation(); - const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => { try { - await updatePassword({ + await authClient.emailPassword.updatePassword({ currentPassword, password, }); @@ -125,7 +122,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { render={({ field }) => ( - Password + New Password diff --git a/apps/web/src/components/forms/profile.tsx b/apps/remix/app/components/forms/profile.tsx similarity index 93% rename from apps/web/src/components/forms/profile.tsx rename to apps/remix/app/components/forms/profile.tsx index 3d70cf672..f6493a6cd 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/remix/app/components/forms/profile.tsx @@ -1,14 +1,11 @@ -'use client'; - -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import type { User } from '@documenso/prisma/client'; +import { useSession } from '@documenso/lib/client-only/providers/session'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -39,14 +36,12 @@ export type TProfileFormSchema = z.infer; export type ProfileFormProps = { className?: string; - user: User; }; -export const ProfileForm = ({ className, user }: ProfileFormProps) => { - const router = useRouter(); - +export const ProfileForm = ({ className }: ProfileFormProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { user, refreshSession } = useSession(); const form = useForm({ values: { @@ -67,13 +62,13 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { signature, }); + await refreshSession(); + toast({ title: _(msg`Profile updated`), description: _(msg`Your profile has been updated successfully.`), duration: 5000, }); - - router.refresh(); } catch (err) { toast({ title: _(msg`An unknown error occurred`), diff --git a/apps/web/src/components/forms/public-profile-form.tsx b/apps/remix/app/components/forms/public-profile-form.tsx similarity index 96% rename from apps/web/src/components/forms/public-profile-form.tsx rename to apps/remix/app/components/forms/public-profile-form.tsx index acdb0d350..6903fc1f9 100644 --- a/apps/web/src/components/forms/public-profile-form.tsx +++ b/apps/remix/app/components/forms/public-profile-form.tsx @@ -1,10 +1,10 @@ -'use client'; - import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Plural, Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Plural, Trans } from '@lingui/react/macro'; +import type { TeamProfile, UserProfile } from '@prisma/client'; import { motion } from 'framer-motion'; import { AnimatePresence } from 'framer-motion'; import { CheckSquareIcon, CopyIcon } from 'lucide-react'; @@ -12,9 +12,8 @@ import { useForm } from 'react-hook-form'; import type { z } from 'zod'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { AppError } from '@documenso/lib/errors/app-error'; import { formatUserProfilePath } from '@documenso/lib/utils/public-profiles'; -import type { TeamProfile, UserProfile } from '@documenso/prisma/client'; import { MAX_PROFILE_BIO_LENGTH, ZUpdatePublicProfileMutationSchema, @@ -90,8 +89,8 @@ export const PublicProfileForm = ({ const error = AppError.parseError(err); switch (error.code) { - case AppErrorCode.PREMIUM_PROFILE_URL: - case AppErrorCode.PROFILE_URL_TAKEN: + case 'PREMIUM_PROFILE_URL': + case 'PROFILE_URL_TAKEN': form.setError('url', { type: 'manual', message: error.message, diff --git a/apps/web/src/components/forms/reset-password.tsx b/apps/remix/app/components/forms/reset-password.tsx similarity index 92% rename from apps/web/src/components/forms/reset-password.tsx rename to apps/remix/app/components/forms/reset-password.tsx index fb8580d96..7930d4e9f 100644 --- a/apps/web/src/components/forms/reset-password.tsx +++ b/apps/remix/app/components/forms/reset-password.tsx @@ -1,16 +1,14 @@ -'use client'; - -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; import { match } from 'ts-pattern'; import { z } from 'zod'; +import { authClient } from '@documenso/auth/client'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { trpc } from '@documenso/trpc/react'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -43,7 +41,7 @@ export type ResetPasswordFormProps = { }; export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) => { - const router = useRouter(); + const navigate = useNavigate(); const { _ } = useLingui(); const { toast } = useToast(); @@ -58,15 +56,15 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) const isSubmitting = form.formState.isSubmitting; - const { mutateAsync: resetPassword } = trpc.profile.resetPassword.useMutation(); - const onFormSubmit = async ({ password }: Omit) => { try { - await resetPassword({ + await authClient.emailPassword.resetPassword({ password, token, }); + await navigate('/signin'); + form.reset(); toast({ @@ -74,8 +72,6 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) description: _(msg`Your password has been updated successfully.`), duration: 5000, }); - - router.push('/signin'); } catch (err) { const error = AppError.parseError(err); diff --git a/apps/web/src/components/forms/search-param-selector.tsx b/apps/remix/app/components/forms/search-param-selector.tsx similarity index 78% rename from apps/web/src/components/forms/search-param-selector.tsx rename to apps/remix/app/components/forms/search-param-selector.tsx index cdd4ef2b2..5992004a5 100644 --- a/apps/web/src/components/forms/search-param-selector.tsx +++ b/apps/remix/app/components/forms/search-param-selector.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; -import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useLocation, useNavigate } from 'react-router'; +import { useSearchParams } from 'react-router'; import { Select, SelectContent, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; @@ -11,10 +12,10 @@ export type SearchParamSelector = { }; export const SearchParamSelector = ({ children, paramKey, isValueValid }: SearchParamSelector) => { - const pathname = usePathname(); - const searchParams = useSearchParams(); + const { pathname } = useLocation(); + const [searchParams] = useSearchParams(); - const router = useRouter(); + const navigate = useNavigate(); const value = useMemo(() => { const p = searchParams?.get(paramKey) ?? 'all'; @@ -35,7 +36,7 @@ export const SearchParamSelector = ({ children, paramKey, isValueValid }: Search params.delete(paramKey); } - router.push(`${pathname}?${params.toString()}`, { scroll: false }); + void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true }); }; return ( diff --git a/apps/web/src/components/forms/send-confirmation-email.tsx b/apps/remix/app/components/forms/send-confirmation-email.tsx similarity index 91% rename from apps/web/src/components/forms/send-confirmation-email.tsx rename to apps/remix/app/components/forms/send-confirmation-email.tsx index a11ee2068..337890de4 100644 --- a/apps/web/src/components/forms/send-confirmation-email.tsx +++ b/apps/remix/app/components/forms/send-confirmation-email.tsx @@ -1,12 +1,11 @@ -'use client'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { trpc } from '@documenso/trpc/react'; +import { authClient } from '@documenso/auth/client'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -43,11 +42,9 @@ export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFo const isSubmitting = form.formState.isSubmitting; - const { mutateAsync: sendConfirmationEmail } = trpc.profile.sendConfirmationEmail.useMutation(); - const onFormSubmit = async ({ email }: TSendConfirmationEmailFormSchema) => { try { - await sendConfirmationEmail({ email }); + await authClient.emailPassword.resendVerifyEmail({ email }); toast({ title: _(msg`Confirmation email sent`), @@ -60,6 +57,7 @@ export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFo form.reset(); } catch (err) { toast({ + variant: 'destructive', title: _(msg`An error occurred while sending your confirmation email`), description: _(msg`Please try again and make sure you enter the correct email address.`), }); diff --git a/apps/web/src/components/forms/signin.tsx b/apps/remix/app/components/forms/signin.tsx similarity index 74% rename from apps/web/src/components/forms/signin.tsx rename to apps/remix/app/components/forms/signin.tsx index 82cd64592..79edbf849 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/remix/app/components/forms/signin.tsx @@ -1,25 +1,22 @@ -'use client'; - import { useEffect, useMemo, useState } from 'react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'; import { KeyRoundIcon } from 'lucide-react'; -import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { FaIdCardClip } from 'react-icons/fa6'; import { FcGoogle } from 'react-icons/fc'; +import { Link, useNavigate } from 'react-router'; import { match } from 'ts-pattern'; import { z } from 'zod'; -import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes'; +import { authClient } from '@documenso/auth/client'; +import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes'; +import { AppError } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; @@ -44,19 +41,19 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -const ERROR_MESSAGES: Partial> = { - [ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect', - [ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect', - [ErrorCode.USER_MISSING_PASSWORD]: - 'This account appears to be using a social login method, please sign in using that method', - [ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect', - [ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect', - [ErrorCode.UNVERIFIED_EMAIL]: - 'This account has not been verified. Please verify your account before signing in.', - [ErrorCode.ACCOUNT_DISABLED]: 'This account has been disabled. Please contact support.', +const CommonErrorMessages: Record = { + [AuthenticationErrorCode.AccountDisabled]: msg`This account has been disabled. Please contact support.`, }; -const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS; +const handleFallbackErrorMessages = (code: string) => { + const message = CommonErrorMessages[code]; + + if (!message) { + return msg`An unknown error occurred`; + } + + return message; +}; const LOGIN_REDIRECT_PATH = '/documents'; @@ -88,9 +85,8 @@ export const SignInForm = ({ }: SignInFormProps) => { const { _ } = useLingui(); const { toast } = useToast(); - const { getFlag } = useFeatureFlags(); - const router = useRouter(); + const navigate = useNavigate(); const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] = useState(false); @@ -101,9 +97,7 @@ export const SignInForm = ({ const [isPasskeyLoading, setIsPasskeyLoading] = useState(false); - const isPasskeyEnabled = getFlag('app_passkey'); - - const callbackUrl = useMemo(() => { + const redirectPath = useMemo(() => { // Handle SSR if (typeof window === 'undefined') { return LOGIN_REDIRECT_PATH; @@ -170,25 +164,20 @@ export const SignInForm = ({ try { setIsPasskeyLoading(true); - const options = await createPasskeySigninOptions(); + const { options, sessionId } = await createPasskeySigninOptions(); const credential = await startAuthentication(options); - const result = await signIn('webauthn', { + await authClient.passkey.signIn({ credential: JSON.stringify(credential), - callbackUrl, - redirect: false, + csrfToken: sessionId, + redirectPath, }); - - if (!result?.url || result.error) { - throw new AppError(result?.error ?? ''); - } - - window.location.href = result.url; } catch (err) { setIsPasskeyLoading(false); - if (err.name === 'NotAllowedError') { + // Error from library. + if (err instanceof Error && err.name === 'NotAllowedError') { return; } @@ -196,12 +185,15 @@ export const SignInForm = ({ const errorMessage = match(error.code) .with( - AppErrorCode.NOT_SETUP, + AuthenticationErrorCode.NotSetup, () => msg`This passkey is not configured for this application. Please login and add one in the user settings.`, ) - .with(AppErrorCode.EXPIRED_CODE, () => msg`This session has expired. Please try again.`) - .otherwise(() => msg`Please try again later or login using your normal details`); + .with( + AuthenticationErrorCode.SessionExpired, + () => msg`This session has expired. Please try again.`, + ) + .otherwise(() => handleFallbackErrorMessages(error.code)); toast({ title: 'Something went wrong', @@ -214,72 +206,59 @@ export const SignInForm = ({ const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => { try { - const credentials: Record = { + await authClient.emailPassword.signIn({ email, password, - }; - - if (totpCode) { - credentials.totpCode = totpCode; - } - - if (backupCode) { - credentials.backupCode = backupCode; - } - - const result = await signIn('credentials', { - ...credentials, - callbackUrl, - redirect: false, + totpCode, + backupCode, + redirectPath, }); + } catch (err) { + console.log(err); - if (result?.error && isErrorCode(result.error)) { - if (result.error === TwoFactorEnabledErrorCode) { - setIsTwoFactorAuthenticationDialogOpen(true); - return; - } + const error = AppError.parseError(err); - const errorMessage = ERROR_MESSAGES[result.error]; + if (error.code === 'TWO_FACTOR_MISSING_CREDENTIALS') { + setIsTwoFactorAuthenticationDialogOpen(true); + return; + } - if (result.error === ErrorCode.UNVERIFIED_EMAIL) { - router.push(`/unverified-account`); - - toast({ - title: _(msg`Unable to sign in`), - description: errorMessage ?? _(msg`An unknown error occurred`), - }); - - return; - } + if (error.code === AuthenticationErrorCode.UnverifiedEmail) { + await navigate('/unverified-account'); toast({ title: _(msg`Unable to sign in`), - description: errorMessage ?? _(msg`An unknown error occurred`), - variant: 'destructive', + description: _( + msg`This account has not been verified. Please verify your account before signing in.`, + ), }); return; } - if (!result?.url) { - throw new Error('An unknown error occurred'); - } + const errorMessage = match(error.code) + .with( + AuthenticationErrorCode.InvalidCredentials, + () => msg`The email or password provided is incorrect`, + ) + .with( + AuthenticationErrorCode.InvalidTwoFactorCode, + () => msg`The two-factor authentication code provided is incorrect`, + ) + .otherwise(() => handleFallbackErrorMessages(error.code)); - window.location.href = result.url; - } catch (err) { toast({ - title: _(msg`An unknown error occurred`), - description: _( - msg`We encountered an unknown error while attempting to sign you In. Please try again later.`, - ), + title: _(msg`Unable to sign in`), + description: _(errorMessage), + variant: 'destructive', }); } }; const onSignInWithGoogleClick = async () => { try { - await signIn('google', { - callbackUrl, + await authClient.google.signIn({ + redirectPath, }); } catch (err) { toast({ @@ -294,8 +273,8 @@ export const SignInForm = ({ const onSignInWithOIDCClick = async () => { try { - await signIn('oidc', { - callbackUrl, + await authClient.oidc.signIn({ + redirectPath, }); } catch (err) { toast({ @@ -365,7 +344,7 @@ export const SignInForm = ({

Forgot your password? @@ -384,7 +363,7 @@ export const SignInForm = ({ {isSubmitting ? Signing in... : Sign In} - {(isGoogleSSOEnabled || isPasskeyEnabled || isOIDCSSOEnabled) && ( + {(isGoogleSSOEnabled || isOIDCSSOEnabled) && (

@@ -422,20 +401,18 @@ export const SignInForm = ({ )} - {isPasskeyEnabled && ( - - )} +
diff --git a/apps/web/src/components/forms/v2/signup.tsx b/apps/remix/app/components/forms/signup.tsx similarity index 90% rename from apps/web/src/components/forms/v2/signup.tsx rename to apps/remix/app/components/forms/signup.tsx index d67e09806..6f9388a0f 100644 --- a/apps/web/src/components/forms/v2/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -1,27 +1,22 @@ -'use client'; - import { useEffect, useState } from 'react'; -import Image from 'next/image'; -import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; import type { MessageDescriptor } from '@lingui/core'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { AnimatePresence, motion } from 'framer-motion'; -import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { FaIdCardClip } from 'react-icons/fa6'; import { FcGoogle } from 'react-icons/fc'; +import { Link, useNavigate, useSearchParams } from 'react-router'; import { z } from 'zod'; import communityCardsImage from '@documenso/assets/images/community-cards.png'; +import { authClient } from '@documenso/auth/client'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { trpc } from '@documenso/trpc/react'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -38,14 +33,12 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { UserProfileSkeleton } from '~/components/ui/user-profile-skeleton'; -import { UserProfileTimur } from '~/components/ui/user-profile-timur'; - -const SIGN_UP_REDIRECT_PATH = '/documents'; +import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton'; +import { UserProfileTimur } from '~/components/general/user-profile-timur'; type SignUpStep = 'BASIC_DETAILS' | 'CLAIM_USERNAME'; -export const ZSignUpFormV2Schema = z +export const ZSignUpFormSchema = z .object({ name: z .string() @@ -78,39 +71,39 @@ export const signupErrorMessages: Record = { SIGNUP_DISABLED: msg`Signups are disabled.`, [AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`, [AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`, - [AppErrorCode.PROFILE_URL_TAKEN]: msg`This username has already been taken`, - [AppErrorCode.PREMIUM_PROFILE_URL]: msg`Only subscribers can have a username shorter than 6 characters`, + PROFILE_URL_TAKEN: msg`This username has already been taken`, + PREMIUM_PROFILE_URL: msg`Only subscribers can have a username shorter than 6 characters`, }; -export type TSignUpFormV2Schema = z.infer; +export type TSignUpFormSchema = z.infer; -export type SignUpFormV2Props = { +export type SignUpFormProps = { className?: string; initialEmail?: string; isGoogleSSOEnabled?: boolean; isOIDCSSOEnabled?: boolean; }; -export const SignUpFormV2 = ({ +export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled, isOIDCSSOEnabled, -}: SignUpFormV2Props) => { +}: SignUpFormProps) => { const { _ } = useLingui(); const { toast } = useToast(); const analytics = useAnalytics(); - const router = useRouter(); - const searchParams = useSearchParams(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [step, setStep] = useState('BASIC_DETAILS'); - const utmSrc = searchParams?.get('utm_source') ?? null; + const utmSrc = searchParams.get('utm_source') ?? null; const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'); - const form = useForm({ + const form = useForm({ values: { name: '', email: initialEmail ?? '', @@ -119,7 +112,7 @@ export const SignUpFormV2 = ({ url: '', }, mode: 'onBlur', - resolver: zodResolver(ZSignUpFormV2Schema), + resolver: zodResolver(ZSignUpFormSchema), }); const isSubmitting = form.formState.isSubmitting; @@ -127,13 +120,17 @@ export const SignUpFormV2 = ({ const name = form.watch('name'); const url = form.watch('url'); - const { mutateAsync: signup } = trpc.auth.signup.useMutation(); - - const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormV2Schema) => { + const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormSchema) => { try { - await signup({ name, email, password, signature, url }); + await authClient.emailPassword.signUp({ + name, + email, + password, + signature, + url, + }); - router.push(`/unverified-account`); + await navigate(`/unverified-account`); toast({ title: _(msg`Registration Successful`), @@ -153,10 +150,7 @@ export const SignUpFormV2 = ({ const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST; - if ( - error.code === AppErrorCode.PROFILE_URL_TAKEN || - error.code === AppErrorCode.PREMIUM_PROFILE_URL - ) { + if (error.code === 'PROFILE_URL_TAKEN' || error.code === 'PREMIUM_PROFILE_URL') { form.setError('url', { type: 'manual', message: _(errorMessage), @@ -181,7 +175,7 @@ export const SignUpFormV2 = ({ const onSignUpWithGoogleClick = async () => { try { - await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH }); + await authClient.google.signIn(); } catch (err) { toast({ title: _(msg`An unknown error occurred`), @@ -195,7 +189,7 @@ export const SignUpFormV2 = ({ const onSignUpWithOIDCClick = async () => { try { - await signIn('oidc', { callbackUrl: SIGN_UP_REDIRECT_PATH }); + await authClient.oidc.signIn(); } catch (err) { toast({ title: _(msg`An unknown error occurred`), @@ -223,11 +217,10 @@ export const SignUpFormV2 = ({
- community-cards
@@ -426,10 +419,7 @@ export const SignUpFormV2 = ({

Already have an account?{' '} - + Sign in instead diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/preferences/branding-preferences.tsx b/apps/remix/app/components/forms/team-branding-preferences-form.tsx similarity index 98% rename from apps/web/src/app/(teams)/t/[teamUrl]/settings/preferences/branding-preferences.tsx rename to apps/remix/app/components/forms/team-branding-preferences-form.tsx index 3f937a0b8..f33345d3b 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/settings/preferences/branding-preferences.tsx +++ b/apps/remix/app/components/forms/team-branding-preferences-form.tsx @@ -1,17 +1,16 @@ -'use client'; - import { useEffect, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { Team, TeamGlobalSettings } from '@prisma/client'; import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { getFile } from '@documenso/lib/universal/upload/get-file'; import { putFile } from '@documenso/lib/universal/upload/put-file'; -import type { Team, TeamGlobalSettings } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/preferences/document-preferences.tsx b/apps/remix/app/components/forms/team-document-preferences-form.tsx similarity index 96% rename from apps/web/src/app/(teams)/t/[teamUrl]/settings/preferences/document-preferences.tsx rename to apps/remix/app/components/forms/team-document-preferences-form.tsx index ed7875ab0..98701b36b 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/settings/preferences/document-preferences.tsx +++ b/apps/remix/app/components/forms/team-document-preferences-form.tsx @@ -1,19 +1,18 @@ -'use client'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { useSession } from 'next-auth/react'; +import { Trans } from '@lingui/react/macro'; +import type { Team, TeamGlobalSettings } from '@prisma/client'; +import { DocumentVisibility } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { useSession } from '@documenso/lib/client-only/providers/session'; import { SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGE_CODES, isValidLanguageCode, } from '@documenso/lib/constants/i18n'; -import type { Team, TeamGlobalSettings } from '@documenso/prisma/client'; -import { DocumentVisibility } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { Alert } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; @@ -56,9 +55,9 @@ export const TeamDocumentPreferencesForm = ({ }: TeamDocumentPreferencesFormProps) => { const { _ } = useLingui(); const { toast } = useToast(); - const { data } = useSession(); + const { user } = useSession(); - const placeholderEmail = data?.user.email ?? 'user@example.com'; + const placeholderEmail = user.email ?? 'user@example.com'; const { mutateAsync: updateTeamDocumentPreferences } = trpc.team.updateTeamDocumentSettings.useMutation(); diff --git a/apps/web/src/components/(teams)/forms/update-team-form.tsx b/apps/remix/app/components/forms/team-update-form.tsx similarity index 87% rename from apps/web/src/components/(teams)/forms/update-team-form.tsx rename to apps/remix/app/components/forms/team-update-form.tsx index be2e7edc2..a5165e30f 100644 --- a/apps/web/src/components/(teams)/forms/update-team-form.tsx +++ b/apps/remix/app/components/forms/team-update-form.tsx @@ -1,15 +1,13 @@ -'use client'; - -import { useRouter } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { AnimatePresence, motion } from 'framer-motion'; import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; import type { z } from 'zod'; -import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; import { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema'; @@ -31,20 +29,20 @@ export type UpdateTeamDialogProps = { teamUrl: string; }; -const ZUpdateTeamFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({ +const ZTeamUpdateFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({ name: true, url: true, }); -type TUpdateTeamFormSchema = z.infer; +type TTeamUpdateFormSchema = z.infer; -export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => { - const router = useRouter(); +export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => { + const navigate = useNavigate(); const { _ } = useLingui(); const { toast } = useToast(); const form = useForm({ - resolver: zodResolver(ZUpdateTeamFormSchema), + resolver: zodResolver(ZTeamUpdateFormSchema), defaultValues: { name: teamName, url: teamUrl, @@ -53,7 +51,7 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation(); - const onFormSubmit = async ({ name, url }: TUpdateTeamFormSchema) => { + const onFormSubmit = async ({ name, url }: TTeamUpdateFormSchema) => { try { await updateTeam({ data: { @@ -75,7 +73,7 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr }); if (url !== teamUrl) { - router.push(`${WEBAPP_BASE_URL}/t/${url}/settings`); + await navigate(`/t/${url}/settings`); } } catch (err) { const error = AppError.parseError(err); @@ -133,7 +131,7 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr {!form.formState.errors.url && ( {field.value ? ( - `${WEBAPP_BASE_URL}/t/${field.value}` + `${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}` ) : ( A unique URL to identify your team )} diff --git a/apps/web/src/components/forms/token.tsx b/apps/remix/app/components/forms/token.tsx similarity index 78% rename from apps/web/src/components/forms/token.tsx rename to apps/remix/app/components/forms/token.tsx index fe2985c52..d7bec0cba 100644 --- a/apps/web/src/components/forms/token.tsx +++ b/apps/remix/app/components/forms/token.tsx @@ -1,12 +1,10 @@ -'use client'; - -import { useState, useTransition } from 'react'; - -import { useRouter } from 'next/navigation'; +import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { ApiToken } from '@prisma/client'; import { AnimatePresence, motion } from 'framer-motion'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; @@ -14,7 +12,6 @@ import { z } from 'zod'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import type { ApiToken } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import type { TCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema'; import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema'; @@ -41,7 +38,15 @@ import { import { Switch } from '@documenso/ui/primitives/switch'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { EXPIRATION_DATES } from '../(dashboard)/settings/token/contants'; +import { useOptionalCurrentTeam } from '~/providers/team'; + +export const EXPIRATION_DATES = { + ONE_WEEK: msg`7 days`, + ONE_MONTH: msg`1 month`, + THREE_MONTHS: msg`3 months`, + SIX_MONTHS: msg`6 months`, + ONE_YEAR: msg`12 months`, +} as const; const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.extend({ enabled: z.boolean(), @@ -56,29 +61,20 @@ type NewlyCreatedToken = { export type ApiTokenFormProps = { className?: string; - teamId?: number; tokens?: Pick[]; }; -export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) => { - const router = useRouter(); - const [isTransitionPending, startTransition] = useTransition(); - +export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => { const [, copy] = useCopyToClipboard(); + const team = useOptionalCurrentTeam(); + const { _ } = useLingui(); const { toast } = useToast(); const [newlyCreatedToken, setNewlyCreatedToken] = useState(); const [noExpirationDate, setNoExpirationDate] = useState(false); - // This lets us hide the token from being copied if it has been deleted without - // resorting to a useEffect or any other fanciness. This comes at the cost of it - // taking slighly longer to appear since it will need to wait for the router.refresh() - // to finish updating. - const hasNewlyCreatedToken = - tokens?.find((token) => token.id === newlyCreatedToken?.id) !== undefined; - const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({ onSuccess(data) { setNewlyCreatedToken(data); @@ -118,7 +114,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) = const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => { try { await createTokenMutation({ - teamId, + teamId: team?.id, tokenName, expirationDate: noExpirationDate ? null : expirationDate, }); @@ -130,8 +126,6 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) = }); form.reset(); - - startTransition(() => router.refresh()); } catch (err) { const error = AppError.parseError(err); @@ -245,7 +239,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) = type="submit" className="hidden md:inline-flex" disabled={!form.formState.isDirty} - loading={form.formState.isSubmitting || isTransitionPending} + loading={form.formState.isSubmitting} > Create token @@ -254,7 +248,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) = @@ -263,34 +257,36 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) = - - {newlyCreatedToken && hasNewlyCreatedToken && ( - - - -

- - Your token was created successfully! Make sure to copy it because you won't be - able to see it again! - -

+ + {newlyCreatedToken && + tokens && + tokens.find((token) => token.id === newlyCreatedToken.id) && ( + + + +

+ + Your token was created successfully! Make sure to copy it because you won't be + able to see it again! + +

-

- {newlyCreatedToken.token} -

+

+ {newlyCreatedToken.token} +

- -
-
-
- )} + + + + + )}
); diff --git a/apps/web/src/app/(dashboard)/admin/stats/signer-conversion-chart.tsx b/apps/remix/app/components/general/admin-stats-signer-conversion-chart.tsx similarity index 92% rename from apps/web/src/app/(dashboard)/admin/stats/signer-conversion-chart.tsx rename to apps/remix/app/components/general/admin-stats-signer-conversion-chart.tsx index 4c5e1bd2c..d8eea476f 100644 --- a/apps/web/src/app/(dashboard)/admin/stats/signer-conversion-chart.tsx +++ b/apps/remix/app/components/general/admin-stats-signer-conversion-chart.tsx @@ -1,23 +1,21 @@ -'use client'; - import { DateTime } from 'luxon'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import type { GetSignerConversionMonthlyResult } from '@documenso/lib/server-only/user/get-signer-conversion'; -export type SignerConversionChartProps = { +export type AdminStatsSignerConversionChartProps = { className?: string; title: string; cummulative?: boolean; data: GetSignerConversionMonthlyResult; }; -export const SignerConversionChart = ({ +export const AdminStatsSignerConversionChart = ({ className, data, title, cummulative = false, -}: SignerConversionChartProps) => { +}: AdminStatsSignerConversionChartProps) => { const formattedData = [...data].reverse().map(({ month, count, cume_count }) => { return { month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('MMM yyyy'), diff --git a/apps/web/src/app/(dashboard)/admin/stats/user-with-document.tsx b/apps/remix/app/components/general/admin-stats-users-with-documents.tsx similarity index 93% rename from apps/web/src/app/(dashboard)/admin/stats/user-with-document.tsx rename to apps/remix/app/components/general/admin-stats-users-with-documents.tsx index bf371b62d..c67b5f010 100644 --- a/apps/web/src/app/(dashboard)/admin/stats/user-with-document.tsx +++ b/apps/remix/app/components/general/admin-stats-users-with-documents.tsx @@ -1,5 +1,3 @@ -'use client'; - import { DateTime } from 'luxon'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import type { TooltipProps } from 'recharts'; @@ -7,7 +5,7 @@ import type { NameType, ValueType } from 'recharts/types/component/DefaultToolti import type { GetUserWithDocumentMonthlyGrowth } from '@documenso/lib/server-only/admin/get-users-stats'; -export type UserWithDocumentChartProps = { +export type AdminStatsUsersWithDocumentsChartProps = { className?: string; title: string; data: GetUserWithDocumentMonthlyGrowth; @@ -23,7 +21,7 @@ const CustomTooltip = ({ }: TooltipProps & { tooltip?: string }) => { if (active && payload && payload.length) { return ( -
+

{label}

{`${tooltip} : `} @@ -36,13 +34,13 @@ const CustomTooltip = ({ return null; }; -export const UserWithDocumentChart = ({ +export const AdminStatsUsersWithDocumentsChart = ({ className, data, title, completed = false, tooltip, -}: UserWithDocumentChartProps) => { +}: AdminStatsUsersWithDocumentsChartProps) => { const formattedData = (data: GetUserWithDocumentMonthlyGrowth, completed: boolean) => { return [...data].reverse().map(({ month, count, signed_count }) => { const formattedMonth = DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL'); diff --git a/apps/remix/app/components/general/app-banner.tsx b/apps/remix/app/components/general/app-banner.tsx new file mode 100644 index 000000000..336aa3718 --- /dev/null +++ b/apps/remix/app/components/general/app-banner.tsx @@ -0,0 +1,28 @@ +import { type TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner'; + +export type AppBannerProps = { + banner: TSiteSettingsBannerSchema; +}; + +export const AppBanner = ({ banner }: AppBannerProps) => { + if (!banner.enabled) { + return null; + } + + return ( +

+
+
+ +
+
+
+ ); +}; + +// Banner +// Custom Text +// Custom Text with Custom Icon diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/remix/app/components/general/app-command-menu.tsx similarity index 87% rename from apps/web/src/components/(dashboard)/common/command-menu.tsx rename to apps/remix/app/components/general/app-command-menu.tsx index e07ed9f1b..d6dcfe395 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/remix/app/components/general/app-command-menu.tsx @@ -1,15 +1,13 @@ -'use client'; - import { useCallback, useMemo, useState } from 'react'; -import { useRouter } from 'next/navigation'; - import type { MessageDescriptor } from '@lingui/core'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react'; -import { useTheme } from 'next-themes'; import { useHotkeys } from 'react-hotkeys-hook'; +import { useNavigate } from 'react-router'; +import { Theme, useTheme } from 'remix-themes'; import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n'; import { @@ -21,7 +19,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META, } from '@documenso/lib/constants/trpc'; -import { switchI18NLanguage } from '@documenso/lib/server-only/i18n/switch-i18n-language'; import { dynamicActivate } from '@documenso/lib/utils/i18n'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -34,7 +31,6 @@ import { CommandList, CommandShortcut, } from '@documenso/ui/primitives/command'; -import { THEMES_TYPE } from '@documenso/ui/primitives/constants'; import { useToast } from '@documenso/ui/primitives/use-toast'; const DOCUMENTS_PAGES = [ @@ -70,22 +66,21 @@ const SETTINGS_PAGES = [ { label: msg`Password`, path: '/settings/password' }, ]; -export type CommandMenuProps = { +export type AppCommandMenuProps = { open?: boolean; onOpenChange?: (_open: boolean) => void; }; -export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { +export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) { const { _ } = useLingui(); - const { setTheme } = useTheme(); - const router = useRouter(); + const navigate = useNavigate(); const [isOpen, setIsOpen] = useState(() => open ?? false); const [search, setSearch] = useState(''); const [pages, setPages] = useState([]); - const { data: searchDocumentsData, isLoading: isSearchingDocuments } = + const { data: searchDocumentsData, isPending: isSearchingDocuments } = trpcReact.document.searchDocuments.useQuery( { query: search, @@ -138,10 +133,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const push = useCallback( (path: string) => { - router.push(path); + void navigate(path); setOpen(false); }, - [router, setOpen], + [setOpen], ); const addPage = (page: string) => { @@ -227,7 +222,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { )} - {currentPage === 'theme' && } + {currentPage === 'theme' && } {currentPage === 'language' && } @@ -256,19 +251,18 @@ const Commands = ({ )); }; -const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => { +const ThemeCommands = () => { const { _ } = useLingui(); - const THEMES = useMemo( - () => [ - { label: msg`Light Mode`, theme: THEMES_TYPE.LIGHT, icon: Sun }, - { label: msg`Dark Mode`, theme: THEMES_TYPE.DARK, icon: Moon }, - { label: msg`System Theme`, theme: THEMES_TYPE.SYSTEM, icon: Monitor }, - ], - [], - ); + const [, setTheme] = useTheme(); - return THEMES.map((theme) => ( + const themes = [ + { label: msg`Light Mode`, theme: Theme.LIGHT, icon: Sun }, + { label: msg`Dark Mode`, theme: Theme.DARK, icon: Moon }, + { label: msg`System Theme`, theme: null, icon: Monitor }, + ] as const; + + return themes.map((theme) => ( setTheme(theme.theme)} @@ -294,9 +288,23 @@ const LanguageCommands = () => { setIsLoading(true); try { - await dynamicActivate(i18n, lang); - await switchI18NLanguage(lang); - } catch (err) { + await dynamicActivate(lang); + + const formData = new FormData(); + + formData.append('lang', lang); + + const response = await fetch('/api/locale', { + method: 'post', + body: formData, + }); + + if (!response.ok) { + throw new Error(response.statusText); + } + } catch (e) { + console.error(`Failed to set language: ${e}`); + toast({ title: _(msg`An unknown error occurred`), variant: 'destructive', diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/remix/app/components/general/app-header.tsx similarity index 77% rename from apps/web/src/components/(dashboard)/layout/header.tsx rename to apps/remix/app/components/general/app-header.tsx index ac50d1145..4d8361e69 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/remix/app/components/general/app-header.tsx @@ -1,32 +1,28 @@ -'use client'; - import { type HTMLAttributes, useEffect, useState } from 'react'; -import Link from 'next/link'; -import { useParams } from 'next/navigation'; -import { usePathname } from 'next/navigation'; - import { MenuIcon, SearchIcon } from 'lucide-react'; +import { Link, useLocation, useParams } from 'react-router'; +import type { SessionUser } from '@documenso/auth/server/lib/session/session'; import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; import { getRootHref } from '@documenso/lib/utils/params'; -import type { User } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; -import { Logo } from '~/components/branding/logo'; +import { BrandingLogo } from '~/components/general/branding-logo'; -import { CommandMenu } from '../common/command-menu'; -import { DesktopNav } from './desktop-nav'; +import { AppCommandMenu } from './app-command-menu'; +import { AppNavDesktop } from './app-nav-desktop'; +import { AppNavMobile } from './app-nav-mobile'; import { MenuSwitcher } from './menu-switcher'; -import { MobileNavigation } from './mobile-navigation'; export type HeaderProps = HTMLAttributes & { - user: User; + user: SessionUser; teams: TGetTeamsResponse; }; export const Header = ({ className, user, teams, ...props }: HeaderProps) => { const params = useParams(); + const { pathname } = useLocation(); const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false); const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false); @@ -42,8 +38,6 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => { return () => window.removeEventListener('scroll', onScroll); }, []); - const pathname = usePathname(); - const isPathTeamUrl = (teamUrl: string) => { if (!pathname || !pathname.startsWith(`/t/`)) { return false; @@ -65,13 +59,13 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => { >
- + - +
{ - + - diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/remix/app/components/general/app-nav-desktop.tsx similarity index 79% rename from apps/web/src/components/(dashboard)/layout/desktop-nav.tsx rename to apps/remix/app/components/general/app-nav-desktop.tsx index 0eea958b7..631fff212 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/remix/app/components/general/app-nav-desktop.tsx @@ -1,12 +1,11 @@ import type { HTMLAttributes } from 'react'; import { useEffect, useState } from 'react'; -import Link from 'next/link'; -import { useParams, usePathname } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { Search } from 'lucide-react'; +import { Link, useLocation, useParams } from 'react-router'; import { getRootHref } from '@documenso/lib/utils/params'; import { cn } from '@documenso/ui/lib/utils'; @@ -23,14 +22,18 @@ const navigationLinks = [ }, ]; -export type DesktopNavProps = HTMLAttributes & { +export type AppNavDesktopProps = HTMLAttributes & { setIsCommandMenuOpen: (value: boolean) => void; }; -export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: DesktopNavProps) => { +export const AppNavDesktop = ({ + className, + setIsCommandMenuOpen, + ...props +}: AppNavDesktopProps) => { const { _ } = useLingui(); - const pathname = usePathname(); + const { pathname } = useLocation(); const params = useParams(); const [modifierKey, setModifierKey] = useState(() => 'Ctrl'); @@ -56,7 +59,7 @@ export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: Deskto {navigationLinks.map(({ href, label }) => ( setIsCommandMenuOpen(true)} >
@@ -82,7 +85,7 @@ export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: Deskto
-
+
{modifierKey}+K
diff --git a/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx b/apps/remix/app/components/general/app-nav-mobile.tsx similarity index 79% rename from apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx rename to apps/remix/app/components/general/app-nav-mobile.tsx index a118233ff..0d48525e1 100644 --- a/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx +++ b/apps/remix/app/components/general/app-nav-mobile.tsx @@ -1,24 +1,20 @@ -'use client'; - -import Image from 'next/image'; -import Link from 'next/link'; -import { useParams } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { signOut } from 'next-auth/react'; +import { Trans } from '@lingui/react/macro'; +import { Link, useParams } from 'react-router'; import LogoImage from '@documenso/assets/logo.png'; +import { authClient } from '@documenso/auth/client'; import { getRootHref } from '@documenso/lib/utils/params'; import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet'; import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher'; -export type MobileNavigationProps = { +export type AppNavMobileProps = { isMenuOpen: boolean; onMenuOpenChange?: (_value: boolean) => void; }; -export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => { +export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps) => { const { _ } = useLingui(); const params = useParams(); @@ -51,8 +47,8 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat return ( - - + Documenso Logo handleMenuItemClick()} > {_(text)} @@ -75,11 +71,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat diff --git a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx b/apps/remix/app/components/general/avatar-with-recipient.tsx similarity index 91% rename from apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx rename to apps/remix/app/components/general/avatar-with-recipient.tsx index 9f29bb06a..f46287abf 100644 --- a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx +++ b/apps/remix/app/components/general/avatar-with-recipient.tsx @@ -1,17 +1,13 @@ -'use client'; - -import React from 'react'; - -import { msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import type { Recipient } from '@prisma/client'; +import { DocumentStatus } from '@prisma/client'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import type { Recipient } from '@documenso/prisma/client'; -import { DocumentStatus } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; diff --git a/apps/web/src/components/ui/background.tsx b/apps/remix/app/components/general/background.tsx similarity index 100% rename from apps/web/src/components/ui/background.tsx rename to apps/remix/app/components/general/background.tsx diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx b/apps/remix/app/components/general/billing-plans.tsx similarity index 94% rename from apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx rename to apps/remix/app/components/general/billing-plans.tsx index 537a0c97b..2113da8fc 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx +++ b/apps/remix/app/components/general/billing-plans.tsx @@ -1,22 +1,20 @@ -'use client'; - import { useState } from 'react'; import type { MessageDescriptor } from '@lingui/core'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { AnimatePresence, motion } from 'framer-motion'; import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price'; +import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { createCheckout } from './create-checkout.action'; - type Interval = keyof PriceIntervals; const INTERVALS: Interval[] = ['day', 'week', 'month', 'year']; @@ -46,11 +44,13 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => { const [interval, setInterval] = useState('month'); const [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState(null); + const { mutateAsync: createCheckoutSession } = trpc.profile.createCheckoutSession.useMutation(); + const onSubscribeClick = async (priceId: string) => { try { setCheckoutSessionPriceId(priceId); - const url = await createCheckout({ priceId }); + const url = await createCheckoutSession({ priceId }); if (!url) { throw new Error('Unable to create session'); diff --git a/apps/remix/app/components/general/billing-portal-button.tsx b/apps/remix/app/components/general/billing-portal-button.tsx new file mode 100644 index 000000000..ea8735954 --- /dev/null +++ b/apps/remix/app/components/general/billing-portal-button.tsx @@ -0,0 +1,48 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type BillingPortalButtonProps = { + buttonProps?: React.ComponentProps; + children?: React.ReactNode; +}; + +export const BillingPortalButton = ({ buttonProps, children }: BillingPortalButtonProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const { mutateAsync: createBillingPortal, isPending } = + trpc.profile.createBillingPortal.useMutation({ + onSuccess: (sessionUrl) => { + window.open(sessionUrl, '_blank'); + }, + onError: (err) => { + let description = _( + msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`, + ); + + if (err.message === 'CUSTOMER_NOT_FOUND') { + description = _( + msg`You do not currently have a customer record, this should not happen. Please contact support for assistance.`, + ); + } + + toast({ + title: _(msg`Something went wrong`), + description, + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + + ); +}; diff --git a/apps/web/src/components/branding/logo.tsx b/apps/remix/app/components/general/branding-logo.tsx similarity index 99% rename from apps/web/src/components/branding/logo.tsx rename to apps/remix/app/components/general/branding-logo.tsx index 92087a149..57932129a 100644 --- a/apps/web/src/components/branding/logo.tsx +++ b/apps/remix/app/components/general/branding-logo.tsx @@ -2,7 +2,7 @@ import type { SVGAttributes } from 'react'; export type LogoProps = SVGAttributes; -export const Logo = ({ ...props }: LogoProps) => { +export const BrandingLogo = ({ ...props }: LogoProps) => { return ( ({ values: { @@ -75,9 +71,9 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) = const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => { try { - await signup({ name, email, password }); + await authClient.emailPassword.signUp({ name, email, password }); - router.push(`/unverified-account`); + await navigate(`/unverified-account`); toast({ title: _(msg`Registration Successful`), diff --git a/apps/web/src/app/(recipient)/d/[token]/configure-direct-template.tsx b/apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx similarity index 79% rename from apps/web/src/app/(recipient)/d/[token]/configure-direct-template.tsx rename to apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx index f9b7019f3..27b01e963 100644 --- a/apps/web/src/app/(recipient)/d/[token]/configure-direct-template.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx @@ -1,15 +1,14 @@ -'use client'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { useSession } from 'next-auth/react'; +import { Trans } from '@lingui/react/macro'; +import type { Recipient } from '@prisma/client'; +import type { Field } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import type { TTemplate } from '@documenso/lib/types/template'; -import type { Recipient } from '@documenso/prisma/client'; -import type { Field } from '@documenso/prisma/client'; import { DocumentFlowFormContainerActions, DocumentFlowFormContainerContent, @@ -30,36 +29,38 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useStep } from '@documenso/ui/primitives/stepper'; -import { useRequiredDocumentAuthContext } from '~/app/(signing)/sign/[token]/document-auth-provider'; +import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider'; -const ZConfigureDirectTemplateFormSchema = z.object({ +const ZDirectTemplateConfigureFormSchema = z.object({ email: z.string().email('Email is invalid'), }); -export type TConfigureDirectTemplateFormSchema = z.infer; +export type TDirectTemplateConfigureFormSchema = z.infer; -export type ConfigureDirectTemplateFormProps = { +export type DirectTemplateConfigureFormProps = { flowStep: DocumentFlowStep; isDocumentPdfLoaded: boolean; template: Omit; directTemplateRecipient: Recipient & { fields: Field[] }; initialEmail?: string; - onSubmit: (_data: TConfigureDirectTemplateFormSchema) => void; + onSubmit: (_data: TDirectTemplateConfigureFormSchema) => void; }; -export const ConfigureDirectTemplateFormPartial = ({ +export const DirectTemplateConfigureForm = ({ flowStep, isDocumentPdfLoaded, template, directTemplateRecipient, initialEmail, onSubmit, -}: ConfigureDirectTemplateFormProps) => { +}: DirectTemplateConfigureFormProps) => { const { _ } = useLingui(); - const { data: session } = useSession(); + + const { sessionData } = useOptionalSession(); + const user = sessionData?.user; const { recipients } = template; - const { derivedRecipientAccessAuth } = useRequiredDocumentAuthContext(); + const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext(); const recipientsWithBlankDirectRecipientEmail = recipients.map((recipient) => { if (recipient.id === directTemplateRecipient.id) { @@ -72,9 +73,9 @@ export const ConfigureDirectTemplateFormPartial = ({ return recipient; }); - const form = useForm({ + const form = useForm({ resolver: zodResolver( - ZConfigureDirectTemplateFormSchema.superRefine((items, ctx) => { + ZDirectTemplateConfigureFormSchema.superRefine((items, ctx) => { if (template.recipients.map((recipient) => recipient.email).includes(items.email)) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -125,7 +126,7 @@ export const ConfigureDirectTemplateFormPartial = ({ disabled={ field.disabled || derivedRecipientAccessAuth !== null || - session?.user.email !== undefined + user?.email !== undefined } placeholder="recipient@documenso.com" /> diff --git a/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx b/apps/remix/app/components/general/direct-template/direct-template-page.tsx similarity index 77% rename from apps/web/src/app/(recipient)/d/[token]/direct-template.tsx rename to apps/remix/app/components/general/direct-template/direct-template-page.tsx index 5a2a99e31..a567f0cda 100644 --- a/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-page.tsx @@ -1,33 +1,34 @@ -'use client'; - import { useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; - -import { msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import type { Field } from '@prisma/client'; +import { type Recipient } from '@prisma/client'; +import { useNavigate, useSearchParams } from 'react-router'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import type { TTemplate } from '@documenso/lib/types/template'; -import type { Field } from '@documenso/prisma/client'; -import { type Recipient } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; -import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { Stepper } from '@documenso/ui/primitives/stepper'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useRequiredDocumentAuthContext } from '~/app/(signing)/sign/[token]/document-auth-provider'; -import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; +import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider'; +import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider'; -import type { TConfigureDirectTemplateFormSchema } from './configure-direct-template'; -import { ConfigureDirectTemplateFormPartial } from './configure-direct-template'; -import type { DirectTemplateLocalField } from './sign-direct-template'; -import { SignDirectTemplateForm } from './sign-direct-template'; +import { + DirectTemplateConfigureForm, + type TDirectTemplateConfigureFormSchema, +} from './direct-template-configure-form'; +import { + type DirectTemplateLocalField, + DirectTemplateSigningForm, +} from './direct-template-signing-form'; -export type TemplatesDirectPageViewProps = { +export type DirectTemplatePageViewProps = { template: Omit; directTemplateToken: string; directTemplateRecipient: Recipient & { fields: Field[] }; @@ -40,15 +41,15 @@ export const DirectTemplatePageView = ({ template, directTemplateRecipient, directTemplateToken, -}: TemplatesDirectPageViewProps) => { - const router = useRouter(); - const searchParams = useSearchParams(); +}: DirectTemplatePageViewProps) => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { _ } = useLingui(); const { toast } = useToast(); - const { email, fullName, setEmail } = useRequiredSigningContext(); - const { recipient, setRecipient } = useRequiredDocumentAuthContext(); + const { email, fullName, setEmail } = useRequiredDocumentSigningContext(); + const { recipient, setRecipient } = useRequiredDocumentSigningAuthContext(); const [step, setStep] = useState('configure'); const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); @@ -76,7 +77,7 @@ export const DirectTemplatePageView = ({ /** * Set the email into a temporary recipient so it can be used for reauth and signing email fields. */ - const onConfigureDirectTemplateSubmit = ({ email }: TConfigureDirectTemplateFormSchema) => { + const onConfigureDirectTemplateSubmit = ({ email }: TDirectTemplateConfigureFormSchema) => { setEmail(email); setRecipient({ @@ -112,7 +113,7 @@ export const DirectTemplatePageView = ({ const redirectUrl = template.templateMeta?.redirectUrl; - redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${token}/complete`); + await (redirectUrl ? navigate(redirectUrl) : navigate(`/sign/${token}/complete`)); } catch (err) { toast({ title: _(msg`Something went wrong`), @@ -135,7 +136,7 @@ export const DirectTemplatePageView = ({ gradient > - setIsDocumentPdfLoaded(true)} @@ -152,7 +153,7 @@ export const DirectTemplatePageView = ({ currentStep={currentDocumentFlow.stepIndex} setCurrentStep={(step) => setStep(DirectTemplateSteps[step - 1])} > - - { try { setIsSigningOut(true); - await signOut({ - callbackUrl: '/signin', - }); + await authClient.signOut(); } catch { toast({ title: _(msg`Something went wrong`), diff --git a/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx similarity index 72% rename from apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx rename to apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index 9e379ab41..26e542f43 100644 --- a/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -1,6 +1,8 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; -import { Trans } from '@lingui/macro'; +import { Trans } from '@lingui/react/macro'; +import type { Field, Recipient, Signature } from '@prisma/client'; +import { FieldType } from '@prisma/client'; import { DateTime } from 'luxon'; import { match } from 'ts-pattern'; @@ -16,8 +18,6 @@ import { } from '@documenso/lib/types/field-meta'; import type { TTemplate } from '@documenso/lib/types/template'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; -import type { Field, Recipient, Signature } from '@documenso/prisma/client'; -import { FieldType } from '@documenso/prisma/client'; import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, @@ -38,20 +38,22 @@ import { Label } from '@documenso/ui/primitives/label'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useStep } from '@documenso/ui/primitives/stepper'; -import { CheckboxField } from '~/app/(signing)/sign/[token]/checkbox-field'; -import { DateField } from '~/app/(signing)/sign/[token]/date-field'; -import { DropdownField } from '~/app/(signing)/sign/[token]/dropdown-field'; -import { EmailField } from '~/app/(signing)/sign/[token]/email-field'; -import { InitialsField } from '~/app/(signing)/sign/[token]/initials-field'; -import { NameField } from '~/app/(signing)/sign/[token]/name-field'; -import { NumberField } from '~/app/(signing)/sign/[token]/number-field'; -import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; -import { RadioField } from '~/app/(signing)/sign/[token]/radio-field'; -import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog'; -import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field'; -import { TextField } from '~/app/(signing)/sign/[token]/text-field'; +import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; +import { DocumentSigningCompleteDialog } from '~/components/general/document-signing/document-signing-complete-dialog'; +import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field'; +import { DocumentSigningDropdownField } from '~/components/general/document-signing/document-signing-dropdown-field'; +import { DocumentSigningEmailField } from '~/components/general/document-signing/document-signing-email-field'; +import { DocumentSigningInitialsField } from '~/components/general/document-signing/document-signing-initials-field'; +import { DocumentSigningNameField } from '~/components/general/document-signing/document-signing-name-field'; +import { DocumentSigningNumberField } from '~/components/general/document-signing/document-signing-number-field'; +import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider'; +import { DocumentSigningRadioField } from '~/components/general/document-signing/document-signing-radio-field'; +import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field'; +import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; -export type SignDirectTemplateFormProps = { +import { DocumentSigningRecipientProvider } from '../document-signing/document-signing-recipient-provider'; + +export type DirectTemplateSigningFormProps = { flowStep: DocumentFlowStep; directRecipient: Recipient; directRecipientFields: Field[]; @@ -64,15 +66,15 @@ export type DirectTemplateLocalField = Field & { signature?: Signature; }; -export const SignDirectTemplateForm = ({ +export const DirectTemplateSigningForm = ({ flowStep, directRecipient, directRecipientFields, template, onSubmit, -}: SignDirectTemplateFormProps) => { +}: DirectTemplateSigningFormProps) => { const { fullName, signature, signatureValid, setFullName, setSignature } = - useRequiredSigningContext(); + useRequiredDocumentSigningContext(); const [localFields, setLocalFields] = useState(directRecipientFields); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); @@ -89,7 +91,7 @@ export const SignDirectTemplateForm = ({ const tempField: DirectTemplateLocalField = { ...field, - customText: value.value, + customText: value.value ?? '', inserted: true, signedValue: value, }; @@ -100,8 +102,8 @@ export const SignDirectTemplateForm = ({ created: new Date(), recipientId: 1, fieldId: 1, - signatureImageAsBase64: value.value.startsWith('data:') ? value.value : null, - typedSignature: value.value.startsWith('data:') ? null : value.value, + signatureImageAsBase64: value.value?.startsWith('data:') ? value.value : null, + typedSignature: value.value && !value.value.startsWith('data:') ? value.value : null, } satisfies Signature; } @@ -168,8 +170,57 @@ export const SignDirectTemplateForm = ({ // Do not reset to false since we do a redirect. }; + useEffect(() => { + const updatedFields = [...localFields]; + + localFields.forEach((field) => { + const index = updatedFields.findIndex((f) => f.id === field.id); + let value = ''; + + match(field.type) + .with(FieldType.TEXT, () => { + const meta = field.fieldMeta ? ZTextFieldMeta.safeParse(field.fieldMeta) : null; + + if (meta?.success) { + value = meta.data.text ?? ''; + } + }) + .with(FieldType.NUMBER, () => { + const meta = field.fieldMeta ? ZNumberFieldMeta.safeParse(field.fieldMeta) : null; + + if (meta?.success) { + value = meta.data.value ?? ''; + } + }) + .with(FieldType.DROPDOWN, () => { + const meta = field.fieldMeta ? ZDropdownFieldMeta.safeParse(field.fieldMeta) : null; + + if (meta?.success) { + value = meta.data.defaultValue ?? ''; + } + }); + + if (value) { + const signedValue = { + token: directRecipient.token, + fieldId: field.id, + value, + }; + + updatedFields[index] = { + ...field, + customText: value, + inserted: true, + signedValue, + }; + } + }); + + setLocalFields(updatedFields); + }, []); + return ( - <> + @@ -183,37 +234,34 @@ export const SignDirectTemplateForm = ({ {localFields.map((field) => match(field.type) .with(FieldType.SIGNATURE, () => ( - )) .with(FieldType.INITIALS, () => ( - )) .with(FieldType.NAME, () => ( - )) .with(FieldType.DATE, () => ( - )) .with(FieldType.EMAIL, () => ( - @@ -235,13 +282,12 @@ export const SignDirectTemplateForm = ({ : null; return ( - @@ -253,13 +299,12 @@ export const SignDirectTemplateForm = ({ : null; return ( - @@ -271,13 +316,12 @@ export const SignDirectTemplateForm = ({ : null; return ( - @@ -289,13 +333,12 @@ export const SignDirectTemplateForm = ({ : null; return ( - @@ -307,13 +350,12 @@ export const SignDirectTemplateForm = ({ : null; return ( - @@ -351,6 +393,7 @@ export const SignDirectTemplateForm = ({ onChange={(value) => { setSignature(value); }} + allowTypedSignature={template.templateMeta?.typedSignatureEnabled} /> @@ -373,7 +416,7 @@ export const SignDirectTemplateForm = ({ Back -
- + ); }; diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx similarity index 88% rename from apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx rename to apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx index 9666b4235..e0b5ca2b1 100644 --- a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx @@ -1,13 +1,13 @@ import { useEffect, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans } from '@lingui/macro'; +import { Trans } from '@lingui/react/macro'; +import { RecipientRole } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { AppError } from '@documenso/lib/errors/app-error'; import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; -import { RecipientRole } from '@documenso/prisma/client'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { DialogFooter } from '@documenso/ui/primitives/dialog'; @@ -23,9 +23,9 @@ import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/ import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog'; -import { useRequiredDocumentAuthContext } from './document-auth-provider'; +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; -export type DocumentActionAuth2FAProps = { +export type DocumentSigningAuth2FAProps = { actionTarget?: 'FIELD' | 'DOCUMENT'; actionVerb?: string; open: boolean; @@ -42,15 +42,15 @@ const Z2FAAuthFormSchema = z.object({ type T2FAAuthFormSchema = z.infer; -export const DocumentActionAuth2FA = ({ +export const DocumentSigningAuth2FA = ({ actionTarget = 'FIELD', actionVerb = 'sign', onReauthFormSubmit, open, onOpenChange, -}: DocumentActionAuth2FAProps) => { +}: DocumentSigningAuth2FAProps) => { const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = - useRequiredDocumentAuthContext(); + useRequiredDocumentSigningAuthContext(); const form = useForm({ resolver: zodResolver(Z2FAAuthFormSchema), @@ -109,17 +109,14 @@ export const DocumentActionAuth2FA = ({ )}

- {user?.identityProvider === 'DOCUMENSO' && ( -

- - By enabling 2FA, you will be required to enter a code from your authenticator app - every time you sign in. - -

- )} +

+ + By enabling 2FA, you will be required to enter a code from your authenticator app + every time you sign in using email password. + +

- - refetchPasskeys()} trigger={
- +
@@ -223,7 +215,7 @@ export const AutoSign = ({ recipient, fields }: AutoSignProps) => { + + + + +
+ {match(role) + .with(RecipientRole.VIEWER, () => Complete Viewing) + .with(RecipientRole.SIGNER, () => Complete Signing) + .with(RecipientRole.APPROVER, () => Complete Approval) + .with(RecipientRole.CC, () => Complete Viewing) + .with(RecipientRole.ASSISTANT, () => Complete Assisting) + .exhaustive()} +
+
+ +
+ {match(role) + .with(RecipientRole.VIEWER, () => ( + + + + You are about to complete viewing " + + {documentTitle} + + ". + +
Are you sure? +
+
+ )) + .with(RecipientRole.SIGNER, () => ( + + + + You are about to complete signing " + + {documentTitle} + + ". + +
Are you sure? +
+
+ )) + .with(RecipientRole.APPROVER, () => ( + + + + You are about to complete approving{' '} + + "{documentTitle}" + + . + +
Are you sure? +
+
+ )) + .otherwise(() => ( + + + + You are about to complete viewing " + + {documentTitle} + + ". + +
Are you sure? +
+
+ ))} +
+ + + + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx similarity index 68% rename from apps/web/src/app/(signing)/sign/[token]/date-field.tsx rename to apps/remix/app/components/general/document-signing/document-signing-date-field.tsx index b62eaf652..09a816b5b 100644 --- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx @@ -1,12 +1,8 @@ -'use client'; - -import { useTransition } from 'react'; - -import { useRouter } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { Loader } from 'lucide-react'; +import { useRevalidator } from 'react-router'; import { DEFAULT_DOCUMENT_DATE_FORMAT, @@ -16,40 +12,39 @@ import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones' import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; -import type { Recipient } from '@documenso/prisma/client'; +import { ZDateFieldMeta } from '@documenso/lib/types/field-meta'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, } from '@documenso/trpc/server/field-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { SigningFieldContainer } from './signing-field-container'; +import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; -export type DateFieldProps = { +export type DocumentSigningDateFieldProps = { field: FieldWithSignature; - recipient: Recipient; dateFormat?: string | null; timezone?: string | null; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const DateField = ({ +export const DocumentSigningDateField = ({ field, - recipient, dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT, timezone = DEFAULT_DOCUMENT_TIME_ZONE, onSignField, onUnsignField, -}: DateFieldProps) => { - const router = useRouter(); - +}: DocumentSigningDateFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { revalidate } = useRevalidator(); - const [isPending, startTransition] = useTransition(); + const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); @@ -59,14 +54,15 @@ export const DateField = ({ isPending: isRemoveSignedFieldWithTokenLoading, } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); - const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading; + + const safeFieldMeta = ZDateFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone); - const isDifferentTime = field.inserted && localDateString !== field.customText; - const tooltipText = _( - msg`"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`, + msg`"${field.customText}" will appear on the document as it has a timezone of "${timezone || ''}".`, ); const onSign = async (authOptions?: TRecipientActionAuth) => { @@ -85,7 +81,7 @@ export const DateField = ({ await signFieldWithToken(payload); - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { const error = AppError.parseError(err); @@ -97,7 +93,9 @@ export const DateField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -117,20 +115,20 @@ export const DateField = ({ await removeSignedFieldWithToken(payload); - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { console.error(err); toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the signature.`), + description: _(msg`An error occurred while removing the field.`), variant: 'destructive', }); } }; return ( - - {localDateString} -

+
+

+ {localDateString} +

+
)} -
+ ); }; diff --git a/apps/web/src/components/general/signing-disclosure.tsx b/apps/remix/app/components/general/document-signing/document-signing-disclosure.tsx similarity index 72% rename from apps/web/src/components/general/signing-disclosure.tsx rename to apps/remix/app/components/general/document-signing/document-signing-disclosure.tsx index a6257d35f..733566145 100644 --- a/apps/web/src/components/general/signing-disclosure.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-disclosure.tsx @@ -1,14 +1,16 @@ import type { HTMLAttributes } from 'react'; -import Link from 'next/link'; - -import { Trans } from '@lingui/macro'; +import { Trans } from '@lingui/react/macro'; +import { Link } from 'react-router'; import { cn } from '@documenso/ui/lib/utils'; -export type SigningDisclosureProps = HTMLAttributes; +export type DocumentSigningDisclosureProps = HTMLAttributes; -export const SigningDisclosure = ({ className, ...props }: SigningDisclosureProps) => { +export const DocumentSigningDisclosure = ({ + className, + ...props +}: DocumentSigningDisclosureProps) => { return (

@@ -22,7 +24,7 @@ export const SigningDisclosure = ({ className, ...props }: SigningDisclosureProp Read the full{' '} signature disclosure diff --git a/apps/web/src/app/(signing)/sign/[token]/dropdown-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx similarity index 84% rename from apps/web/src/app/(signing)/sign/[token]/dropdown-field.tsx rename to apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx index 5f4e1a444..b2d5a4b0f 100644 --- a/apps/web/src/app/(signing)/sign/[token]/dropdown-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx @@ -1,18 +1,14 @@ -'use client'; +import { useEffect, useState } from 'react'; -import { useEffect, useState, useTransition } from 'react'; - -import { useRouter } from 'next/navigation'; - -import { msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Loader } from 'lucide-react'; +import { useRevalidator } from 'react-router'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZDropdownFieldMeta } from '@documenso/lib/types/field-meta'; -import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import { trpc } from '@documenso/trpc/react'; import type { @@ -29,29 +25,28 @@ import { } from '@documenso/ui/primitives/select'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useRequiredDocumentAuthContext } from './document-auth-provider'; -import { SigningFieldContainer } from './signing-field-container'; +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; -export type DropdownFieldProps = { +export type DocumentSigningDropdownFieldProps = { field: FieldWithSignatureAndFieldMeta; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const DropdownField = ({ +export const DocumentSigningDropdownField = ({ field, - recipient, onSignField, onUnsignField, -}: DropdownFieldProps) => { +}: DocumentSigningDropdownFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { revalidate } = useRevalidator(); - const router = useRouter(); - const [isPending, startTransition] = useTransition(); + const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); - const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const parsedFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta); const isReadOnly = parsedFieldMeta?.readOnly; @@ -66,7 +61,7 @@ export const DropdownField = ({ isPending: isRemoveSignedFieldWithTokenLoading, } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); - const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading; const shouldAutoSignField = (!field.inserted && localChoice) || (!field.inserted && isReadOnly && defaultValue); @@ -91,7 +86,8 @@ export const DropdownField = ({ } setLocalChoice(''); - startTransition(() => router.refresh()); + + await revalidate(); } catch (err) { const error = AppError.parseError(err); @@ -103,7 +99,9 @@ export const DropdownField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -128,13 +126,14 @@ export const DropdownField = ({ } setLocalChoice(''); - startTransition(() => router.refresh()); + + await revalidate(); } catch (err) { console.error(err); toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the signature.`), + description: _(msg`An error occurred while removing the field.`), variant: 'destructive', }); } @@ -164,7 +163,7 @@ export const DropdownField = ({ return (

- )} - +
); }; diff --git a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx similarity index 60% rename from apps/web/src/app/(signing)/sign/[token]/email-field.tsx rename to apps/remix/app/components/general/document-signing/document-signing-email-field.tsx index 9300aef63..a7ebc1dbe 100644 --- a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx @@ -1,44 +1,44 @@ -'use client'; - -import { useTransition } from 'react'; - -import { useRouter } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { Loader } from 'lucide-react'; +import { useRevalidator } from 'react-router'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; -import type { Recipient } from '@documenso/prisma/client'; +import { ZEmailFieldMeta } from '@documenso/lib/types/field-meta'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, } from '@documenso/trpc/server/field-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useRequiredSigningContext } from './provider'; -import { SigningFieldContainer } from './signing-field-container'; +import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useRequiredDocumentSigningContext } from './document-signing-provider'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; -export type EmailFieldProps = { +export type DocumentSigningEmailFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const EmailField = ({ field, recipient, onSignField, onUnsignField }: EmailFieldProps) => { - const router = useRouter(); - +export const DocumentSigningEmailField = ({ + field, + onSignField, + onUnsignField, +}: DocumentSigningEmailFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { revalidate } = useRevalidator(); - const { email: providedEmail } = useRequiredSigningContext(); + const { email: providedEmail } = useRequiredDocumentSigningContext(); - const [isPending, startTransition] = useTransition(); + const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext(); const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); @@ -48,7 +48,10 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema isPending: isRemoveSignedFieldWithTokenLoading, } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); - const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading; + + const safeFieldMeta = ZEmailFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; const onSign = async (authOptions?: TRecipientActionAuth) => { try { @@ -69,7 +72,7 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema await signFieldWithToken(payload); - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { const error = AppError.parseError(err); @@ -81,7 +84,9 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -101,20 +106,20 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema await removeSignedFieldWithToken(payload); - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { console.error(err); toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the signature.`), + description: _(msg`An error occurred while removing the field.`), variant: 'destructive', }); } }; return ( - + {isLoading && (
@@ -128,10 +133,22 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema )} {field.inserted && ( -

- {field.customText} -

+
+

+ {field.customText} +

+
)} - + ); }; diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx b/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx similarity index 85% rename from apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx rename to apps/remix/app/components/general/document-signing/document-signing-field-container.tsx index cf8403696..14fe95c44 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx @@ -1,21 +1,19 @@ -'use client'; - import React from 'react'; -import { Trans } from '@lingui/macro'; +import { Trans } from '@lingui/react/macro'; +import { FieldType } from '@prisma/client'; import { X } from 'lucide-react'; import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; -import { FieldType } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { FieldRootContainer } from '@documenso/ui/components/field/field'; import { cn } from '@documenso/ui/lib/utils'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; -import { useRequiredDocumentAuthContext } from './document-auth-provider'; +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; -export type SignatureFieldProps = { +export type DocumentSigningFieldContainerProps = { field: FieldWithSignature; loading?: boolean; children: React.ReactNode; @@ -46,6 +44,7 @@ export type SignatureFieldProps = { | 'Email' | 'Name' | 'Signature' + | 'Text' | 'Radio' | 'Dropdown' | 'Number' @@ -53,7 +52,7 @@ export type SignatureFieldProps = { tooltipText?: string | null; }; -export const SigningFieldContainer = ({ +export const DocumentSigningFieldContainer = ({ field, loading, onPreSign, @@ -62,8 +61,9 @@ export const SigningFieldContainer = ({ children, type, tooltipText, -}: SignatureFieldProps) => { - const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext(); +}: DocumentSigningFieldContainerProps) => { + const { executeActionAuthProcedure, isAuthRedirectRequired } = + useRequiredDocumentSigningAuthContext(); const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined; const readOnlyField = parsedFieldMeta?.readOnly || false; @@ -181,6 +181,23 @@ export const SigningFieldContainer = ({ )} + {(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) && + field.fieldMeta?.label && ( +
+ {field.fieldMeta.label} +
+ )} + {children}
diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx new file mode 100644 index 000000000..a293afac8 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -0,0 +1,410 @@ +import { useId, useMemo, useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client'; +import { Controller, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; + +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; +import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; +import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; +import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; +import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; +import { trpc } from '@documenso/trpc/react'; +import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; +import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog'; +import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; +import { useRequiredDocumentSigningContext } from './document-signing-provider'; + +export type DocumentSigningFormProps = { + document: DocumentAndSender; + recipient: Recipient; + fields: Field[]; + redirectUrl?: string | null; + isRecipientsTurn: boolean; + allRecipients?: RecipientWithFields[]; + setSelectedSignerId?: (id: number | null) => void; +}; + +export const DocumentSigningForm = ({ + document, + recipient, + fields, + redirectUrl, + isRecipientsTurn, + allRecipients = [], + setSelectedSignerId, +}: DocumentSigningFormProps) => { + const { sessionData } = useOptionalSession(); + const user = sessionData?.user; + + const { _ } = useLingui(); + const { toast } = useToast(); + + const navigate = useNavigate(); + const analytics = useAnalytics(); + + const assistantSignersId = useId(); + + const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } = + useRequiredDocumentSigningContext(); + + const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); + const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false); + const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false); + + const { mutateAsync: completeDocumentWithToken } = + trpc.recipient.completeDocumentWithToken.useMutation(); + + const assistantForm = useForm<{ selectedSignerId: number | undefined }>({ + defaultValues: { + selectedSignerId: undefined, + }, + }); + + const { handleSubmit, formState } = useForm(); + + // Keep the loading state going if successful since the redirect may take some time. + const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful; + + const fieldsRequiringValidation = useMemo( + () => fields.filter(isFieldUnsignedAndRequired), + [fields], + ); + + const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); + + const uninsertedFields = useMemo(() => { + return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted)); + }, [fieldsRequiringValidation]); + + const uninsertedRecipientFields = useMemo(() => { + return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id); + }, [fieldsRequiringValidation, recipient]); + + const fieldsValidated = () => { + setValidateUninsertedFields(true); + validateFieldsInserted(fieldsRequiringValidation); + }; + + const onFormSubmit = async () => { + setValidateUninsertedFields(true); + + const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation); + + if (hasSignatureField && !signatureValid) { + return; + } + + if (!isFieldsValid) { + return; + } + + await completeDocument(); + }; + + const onAssistantFormSubmit = () => { + if (uninsertedRecipientFields.length > 0) { + return; + } + + setIsConfirmationDialogOpen(true); + }; + + const handleAssistantConfirmDialogSubmit = async () => { + setIsAssistantSubmitting(true); + + try { + await completeDocument(); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while completing the document. Please try again.', + variant: 'destructive', + }); + + setIsAssistantSubmitting(false); + setIsConfirmationDialogOpen(false); + } + }; + + const completeDocument = async (authOptions?: TRecipientActionAuth) => { + await completeDocumentWithToken({ + token: recipient.token, + documentId: document.id, + authOptions, + }); + + analytics.capture('App: Recipient has completed signing', { + signerId: recipient.id, + documentId: document.id, + timestamp: new Date().toISOString(), + }); + + if (redirectUrl) { + window.location.href = redirectUrl; + } else { + await navigate(`/sign/${recipient.token}/complete`); + } + }; + + return ( +
+ {validateUninsertedFields && uninsertedFields[0] && ( + + Click to insert field + + )} + +
+
+

+ {recipient.role === RecipientRole.VIEWER && View Document} + {recipient.role === RecipientRole.SIGNER && Sign Document} + {recipient.role === RecipientRole.APPROVER && Approve Document} + {recipient.role === RecipientRole.ASSISTANT && Assist Document} +

+ + {recipient.role === RecipientRole.VIEWER ? ( + <> +

+ Please mark as viewed to complete +

+ +
+ +
+
+
+ + + +
+
+ + ) : recipient.role === RecipientRole.ASSISTANT ? ( + <> + +

+ + Complete the fields for the following signers. Once reviewed, they will inform + you if any modifications are needed. + +

+ +
+ +
+ ( + { + field.onChange(value); + setSelectedSignerId?.(Number(value)); + }} + > + {allRecipients + .filter((r) => r.fields.length > 0) + .map((r) => ( +
+
+
+ + +
+ +

{r.email}

+
+
+
+ {r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'} +
+
+
+ ))} +
+ )} + /> +
+ +
+ +
+ + 0} + isOpen={isConfirmationDialogOpen} + onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)} + onConfirm={handleAssistantConfirmDialogSubmit} + isSubmitting={isAssistantSubmitting} + /> + + + ) : ( + <> +
+

+ {recipient.role === RecipientRole.APPROVER && !hasSignatureField ? ( + Please review the document before approving. + ) : ( + Please review the document before signing. + )} +

+ +
+ +
+
+
+ + + setFullName(e.target.value.trimStart())} + /> +
+ + {hasSignatureField && ( +
+ + + + + { + setSignatureValid(isValid); + }} + onChange={(value) => { + if (signatureValid) { + setSignature(value); + } + }} + allowTypedSignature={document.documentMeta?.typedSignatureEnabled} + /> + + + + {!signatureValid && ( +
+ + Signature is too small. Please provide a more complete signature. + +
+ )} +
+ )} +
+ +
+ + + +
+
+
+ + )} +
+
+
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/initials-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx similarity index 75% rename from apps/web/src/app/(signing)/sign/[token]/initials-field.tsx rename to apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx index b63418076..532b0cc4b 100644 --- a/apps/web/src/app/(signing)/sign/[token]/initials-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx @@ -1,18 +1,13 @@ -'use client'; - -import { useTransition } from 'react'; - -import { useRouter } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { Loader } from 'lucide-react'; +import { useRevalidator } from 'react-router'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; -import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { @@ -21,31 +16,30 @@ import type { } from '@documenso/trpc/server/field-router/schema'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useRequiredSigningContext } from './provider'; -import { SigningFieldContainer } from './signing-field-container'; +import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useRequiredDocumentSigningContext } from './document-signing-provider'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; -export type InitialsFieldProps = { +export type DocumentSigningInitialsFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const InitialsField = ({ +export const DocumentSigningInitialsField = ({ field, - recipient, onSignField, onUnsignField, -}: InitialsFieldProps) => { - const router = useRouter(); +}: DocumentSigningInitialsFieldProps) => { const { toast } = useToast(); const { _ } = useLingui(); + const { revalidate } = useRevalidator(); + + const { fullName } = useRequiredDocumentSigningContext(); + const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); - const { fullName } = useRequiredSigningContext(); const initials = extractInitials(fullName); - const [isPending, startTransition] = useTransition(); - const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); @@ -54,7 +48,7 @@ export const InitialsField = ({ isPending: isRemoveSignedFieldWithTokenLoading, } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); - const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading; const onSign = async (authOptions?: TRecipientActionAuth) => { try { @@ -75,7 +69,7 @@ export const InitialsField = ({ await signFieldWithToken(payload); - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { const error = AppError.parseError(err); @@ -87,7 +81,9 @@ export const InitialsField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -107,7 +103,7 @@ export const InitialsField = ({ await removeSignedFieldWithToken(payload); - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { console.error(err); @@ -120,7 +116,12 @@ export const InitialsField = ({ }; return ( - + {isLoading && (
@@ -138,6 +139,6 @@ export const InitialsField = ({ {field.customText}

)} - + ); }; diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx similarity index 71% rename from apps/web/src/app/(signing)/sign/[token]/name-field.tsx rename to apps/remix/app/components/general/document-signing/document-signing-name-field.tsx index bc83e5a49..7c0246c97 100644 --- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx @@ -1,52 +1,54 @@ -'use client'; +import { useState } from 'react'; -import { useState, useTransition } from 'react'; - -import { useRouter } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { Loader } from 'lucide-react'; +import { useRevalidator } from 'react-router'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; -import { type Recipient } from '@documenso/prisma/client'; +import { ZNameFieldMeta } from '@documenso/lib/types/field-meta'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, } from '@documenso/trpc/server/field-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useRequiredDocumentAuthContext } from './document-auth-provider'; -import { useRequiredSigningContext } from './provider'; -import { SigningFieldContainer } from './signing-field-container'; +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useRequiredDocumentSigningContext } from './document-signing-provider'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; -export type NameFieldProps = { +export type DocumentSigningNameFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const NameField = ({ field, recipient, onSignField, onUnsignField }: NameFieldProps) => { - const router = useRouter(); - +export const DocumentSigningNameField = ({ + field, + onSignField, + onUnsignField, +}: DocumentSigningNameFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { revalidate } = useRevalidator(); const { fullName: providedFullName, setFullName: setProvidedFullName } = - useRequiredSigningContext(); + useRequiredDocumentSigningContext(); - const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); + const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); - const [isPending, startTransition] = useTransition(); + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); @@ -56,13 +58,16 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name isPending: isRemoveSignedFieldWithTokenLoading, } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); - const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading; + + const safeFieldMeta = ZNameFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; const [showFullNameModal, setShowFullNameModal] = useState(false); const [localFullName, setLocalFullName] = useState(''); const onPreSign = () => { - if (!providedFullName) { + if (!providedFullName && !isAssistantMode) { setShowFullNameModal(true); return false; } @@ -85,9 +90,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => { try { - const value = name || providedFullName; + const value = name || providedFullName || ''; - if (!value) { + if (!value && !isAssistantMode) { setShowFullNameModal(true); return; } @@ -107,7 +112,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name await signFieldWithToken(payload); - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { const error = AppError.parseError(err); @@ -119,7 +124,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -139,20 +146,20 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name await removeSignedFieldWithToken(payload); - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { console.error(err); toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the signature.`), + description: _(msg`An error occurred while removing the field.`), variant: 'destructive', }); } }; return ( - - {field.customText} -

+
+

+ {field.customText} +

+
)} @@ -227,6 +246,6 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name -
+ ); }; diff --git a/apps/web/src/app/(signing)/sign/[token]/number-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx similarity index 80% rename from apps/web/src/app/(signing)/sign/[token]/number-field.tsx rename to apps/remix/app/components/general/document-signing/document-signing-number-field.tsx index ffd90df64..307225778 100644 --- a/apps/web/src/app/(signing)/sign/[token]/number-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx @@ -1,19 +1,16 @@ -'use client'; +import { useEffect, useState } from 'react'; -import { useEffect, useState, useTransition } from 'react'; - -import { useRouter } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { Hash, Loader } from 'lucide-react'; +import { useRevalidator } from 'react-router'; import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZNumberFieldMeta } from '@documenso/lib/types/field-meta'; -import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { @@ -26,8 +23,9 @@ import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/ import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useRequiredDocumentAuthContext } from './document-auth-provider'; -import { SigningFieldContainer } from './signing-field-container'; +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; type ValidationErrors = { isNumber: string[]; @@ -37,23 +35,28 @@ type ValidationErrors = { numberFormat: string[]; }; -export type NumberFieldProps = { +export type DocumentSigningNumberFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const NumberField = ({ field, recipient, onSignField, onUnsignField }: NumberFieldProps) => { +export const DocumentSigningNumberField = ({ + field, + onSignField, + onUnsignField, +}: DocumentSigningNumberFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { revalidate } = useRevalidator(); - const router = useRouter(); - const [isPending, startTransition] = useTransition(); - const [showRadioModal, setShowRadioModal] = useState(false); + const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext(); + + const [showNumberModal, setShowNumberModal] = useState(false); + + const safeFieldMeta = ZNumberFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; - const parsedFieldMeta = field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null; - const isReadOnly = parsedFieldMeta?.readOnly; const defaultValue = parsedFieldMeta?.value; const [localNumber, setLocalNumber] = useState( parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0', @@ -69,7 +72,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu const [errors, setErrors] = useState(initialErrors); - const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); @@ -79,7 +82,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu isPending: isRemoveSignedFieldWithTokenLoading, } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); - const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading; const handleNumberChange = (e: React.ChangeEvent) => { const text = e.target.value; @@ -104,7 +107,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu }; const onDialogSignClick = () => { - setShowRadioModal(false); + setShowNumberModal(false); void executeActionAuthProcedure({ onReauthFormSubmit: async (authOptions) => await onSign(authOptions), @@ -135,7 +138,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu setLocalNumber(''); - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { const error = AppError.parseError(err); @@ -147,14 +150,20 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } }; const onPreSign = () => { - setShowRadioModal(true); + if (isAssistantMode) { + return true; + } + + setShowNumberModal(true); if (localNumber && parsedFieldMeta) { const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true); @@ -186,29 +195,29 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta?.value) : ''); - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { console.error(err); toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the signature.`), + description: _(msg`An error occurred while removing the field.`), variant: 'destructive', }); } }; useEffect(() => { - if (!showRadioModal) { + if (!showNumberModal) { setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0'); setErrors(initialErrors); } - }, [showRadioModal]); + }, [showNumberModal]); useEffect(() => { if ( (!field.inserted && defaultValue && localNumber) || - (!field.inserted && isReadOnly && defaultValue) + (!field.inserted && parsedFieldMeta?.readOnly && defaultValue) ) { void executeActionAuthProcedure({ onReauthFormSubmit: async (authOptions) => await onSign(authOptions), @@ -221,20 +230,20 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu if (parsedFieldMeta?.label) { fieldDisplayName = - parsedFieldMeta.label.length > 10 - ? parsedFieldMeta.label.substring(0, 10) + '...' + parsedFieldMeta.label.length > 20 + ? parsedFieldMeta.label.substring(0, 20) + '...' : parsedFieldMeta.label; } const userInputHasErrors = Object.values(errors).some((error) => error.length > 0); return ( - {isLoading && (
@@ -260,12 +269,24 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu )} {field.inserted && ( -

- {field.customText} -

+
+

+ {field.customText} +

+
)} - + {parsedFieldMeta?.label ? parsedFieldMeta?.label : Number} @@ -321,7 +342,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10" variant="secondary" onClick={() => { - setShowRadioModal(false); + setShowNumberModal(false); setLocalNumber(''); }} > @@ -340,6 +361,6 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu - + ); }; diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx new file mode 100644 index 000000000..ddb9dc555 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx @@ -0,0 +1,240 @@ +import { useState } from 'react'; + +import { Trans } from '@lingui/react/macro'; +import type { Field } from '@prisma/client'; +import { FieldType, RecipientRole } from '@prisma/client'; +import { match } from 'ts-pattern'; + +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; +import { + ZCheckboxFieldMeta, + ZDropdownFieldMeta, + ZNumberFieldMeta, + ZRadioFieldMeta, + ZTextFieldMeta, +} from '@documenso/lib/types/field-meta'; +import type { CompletedField } from '@documenso/lib/types/fields'; +import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; +import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { ElementVisible } from '@documenso/ui/primitives/element-visible'; +import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; + +import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign'; +import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; +import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field'; +import { DocumentSigningDropdownField } from '~/components/general/document-signing/document-signing-dropdown-field'; +import { DocumentSigningEmailField } from '~/components/general/document-signing/document-signing-email-field'; +import { DocumentSigningForm } from '~/components/general/document-signing/document-signing-form'; +import { DocumentSigningInitialsField } from '~/components/general/document-signing/document-signing-initials-field'; +import { DocumentSigningNameField } from '~/components/general/document-signing/document-signing-name-field'; +import { DocumentSigningNumberField } from '~/components/general/document-signing/document-signing-number-field'; +import { DocumentSigningRadioField } from '~/components/general/document-signing/document-signing-radio-field'; +import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog'; +import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field'; +import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; +import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields'; + +import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider'; + +export type SigningPageViewProps = { + document: DocumentAndSender; + recipient: RecipientWithFields; + fields: Field[]; + completedFields: CompletedField[]; + isRecipientsTurn: boolean; + allRecipients?: RecipientWithFields[]; +}; + +export const DocumentSigningPageView = ({ + document, + recipient, + fields, + completedFields, + isRecipientsTurn, + allRecipients = [], +}: SigningPageViewProps) => { + const { documentData, documentMeta } = document; + + const [selectedSignerId, setSelectedSignerId] = useState(allRecipients?.[0]?.id); + + const shouldUseTeamDetails = + document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false; + + let senderName = document.user.name ?? ''; + let senderEmail = `(${document.user.email})`; + + if (shouldUseTeamDetails) { + senderName = document.team?.name ?? ''; + senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : ''; + } + + const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId); + + return ( + +
+

+ {document.title} +

+ +
+
+ + {senderName} {senderEmail} + {' '} + + {match(recipient.role) + .with(RecipientRole.VIEWER, () => + document.teamId && !shouldUseTeamDetails ? ( + + on behalf of "{document.team?.name}" has invited you to view this document + + ) : ( + has invited you to view this document + ), + ) + .with(RecipientRole.SIGNER, () => + document.teamId && !shouldUseTeamDetails ? ( + + on behalf of "{document.team?.name}" has invited you to sign this document + + ) : ( + has invited you to sign this document + ), + ) + .with(RecipientRole.APPROVER, () => + document.teamId && !shouldUseTeamDetails ? ( + + on behalf of "{document.team?.name}" has invited you to approve this document + + ) : ( + has invited you to approve this document + ), + ) + .with(RecipientRole.ASSISTANT, () => + document.teamId && !shouldUseTeamDetails ? ( + + on behalf of "{document.team?.name}" has invited you to assist this document + + ) : ( + has invited you to assist this document + ), + ) + .otherwise(() => null)} + +
+ + +
+ +
+ + + + + + +
+ +
+
+ + + + {recipient.role !== RecipientRole.ASSISTANT && ( + + )} + + + {fields + .filter( + (field) => + recipient.role !== RecipientRole.ASSISTANT || + field.recipientId === selectedSigner?.id, + ) + .map((field) => + match(field.type) + .with(FieldType.SIGNATURE, () => ( + + )) + .with(FieldType.INITIALS, () => ( + + )) + .with(FieldType.NAME, () => ( + + )) + .with(FieldType.DATE, () => ( + + )) + .with(FieldType.EMAIL, () => ( + + )) + .with(FieldType.TEXT, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) + .with(FieldType.NUMBER, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) + .with(FieldType.RADIO, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) + .with(FieldType.CHECKBOX, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) + .with(FieldType.DROPDOWN, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) + .otherwise(() => null), + )} + +
+
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/provider.tsx b/apps/remix/app/components/general/document-signing/document-signing-provider.tsx similarity index 67% rename from apps/web/src/app/(signing)/sign/[token]/provider.tsx rename to apps/remix/app/components/general/document-signing/document-signing-provider.tsx index 3e491bf32..ca231949d 100644 --- a/apps/web/src/app/(signing)/sign/[token]/provider.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-provider.tsx @@ -1,8 +1,6 @@ -'use client'; - import { createContext, useContext, useEffect, useState } from 'react'; -export type SigningContextValue = { +export type DocumentSigningContextValue = { fullName: string; setFullName: (_value: string) => void; email: string; @@ -13,14 +11,14 @@ export type SigningContextValue = { setSignatureValid: (_valid: boolean) => void; }; -const SigningContext = createContext(null); +const DocumentSigningContext = createContext(null); -export const useSigningContext = () => { - return useContext(SigningContext); +export const useDocumentSigningContext = () => { + return useContext(DocumentSigningContext); }; -export const useRequiredSigningContext = () => { - const context = useSigningContext(); +export const useRequiredDocumentSigningContext = () => { + const context = useDocumentSigningContext(); if (!context) { throw new Error('Signing context is required'); @@ -29,19 +27,19 @@ export const useRequiredSigningContext = () => { return context; }; -export interface SigningProviderProps { +export interface DocumentSigningProviderProps { fullName?: string | null; email?: string | null; signature?: string | null; children: React.ReactNode; } -export const SigningProvider = ({ +export const DocumentSigningProvider = ({ fullName: initialFullName, email: initialEmail, signature: initialSignature, children, -}: SigningProviderProps) => { +}: DocumentSigningProviderProps) => { const [fullName, setFullName] = useState(initialFullName || ''); const [email, setEmail] = useState(initialEmail || ''); const [signature, setSignature] = useState(initialSignature || null); @@ -54,7 +52,7 @@ export const SigningProvider = ({ }, [initialSignature]); return ( - {children} - + ); }; -SigningProvider.displayName = 'SigningProvider'; +DocumentSigningProvider.displayName = 'DocumentSigningProvider'; diff --git a/apps/web/src/app/(signing)/sign/[token]/radio-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx similarity index 82% rename from apps/web/src/app/(signing)/sign/[token]/radio-field.tsx rename to apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx index 398181ec1..b518c7bb3 100644 --- a/apps/web/src/app/(signing)/sign/[token]/radio-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx @@ -1,18 +1,14 @@ -'use client'; +import { useEffect, useState } from 'react'; -import { useEffect, useState, useTransition } from 'react'; - -import { useRouter } from 'next/navigation'; - -import { msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Loader } from 'lucide-react'; +import { useRevalidator } from 'react-router'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta'; -import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import { trpc } from '@documenso/trpc/react'; import type { @@ -23,22 +19,26 @@ import { Label } from '@documenso/ui/primitives/label'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useRequiredDocumentAuthContext } from './document-auth-provider'; -import { SigningFieldContainer } from './signing-field-container'; +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; -export type RadioFieldProps = { +export type DocumentSigningRadioFieldProps = { field: FieldWithSignatureAndFieldMeta; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const RadioField = ({ field, recipient, onSignField, onUnsignField }: RadioFieldProps) => { +export const DocumentSigningRadioField = ({ + field, + onSignField, + onUnsignField, +}: DocumentSigningRadioFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { revalidate } = useRevalidator(); - const router = useRouter(); - const [isPending, startTransition] = useTransition(); + const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext(); const parsedFieldMeta = ZRadioFieldMeta.parse(field.fieldMeta); const values = parsedFieldMeta.values?.map((item) => ({ @@ -50,7 +50,7 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad const [selectedOption, setSelectedOption] = useState(defaultValue); - const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); @@ -60,7 +60,7 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad isPending: isRemoveSignedFieldWithTokenLoading, } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); - const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading; const shouldAutoSignField = (!field.inserted && selectedOption) || (!field.inserted && defaultValue) || @@ -87,7 +87,8 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad } setSelectedOption(''); - startTransition(() => router.refresh()); + + await revalidate(); } catch (err) { const error = AppError.parseError(err); @@ -99,7 +100,9 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -120,13 +123,13 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad setSelectedOption(''); - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { console.error(err); toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the signature.`), + description: _(msg`An error occurred while removing the selection.`), variant: 'destructive', }); } @@ -146,7 +149,7 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad }, [selectedOption, field]); return ( - + {isLoading && (
@@ -189,6 +192,6 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad ))} )} - + ); }; diff --git a/apps/remix/app/components/general/document-signing/document-signing-recipient-provider.tsx b/apps/remix/app/components/general/document-signing/document-signing-recipient-provider.tsx new file mode 100644 index 000000000..96a051d56 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-recipient-provider.tsx @@ -0,0 +1,67 @@ +import { type PropsWithChildren, createContext, useContext } from 'react'; + +import type { Recipient } from '@prisma/client'; + +import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; + +export interface DocumentSigningRecipientContextValue { + /** + * The recipient who is currently signing the document. + * In regular mode, this is the actual signer. + * In assistant mode, this is the recipient who is helping fill out the document. + */ + recipient: Recipient | RecipientWithFields; + + /** + * Only present in assistant mode. + * The recipient on whose behalf we're filling out the document. + */ + targetSigner: RecipientWithFields | null; + + /** + * Whether we're in assistant mode (one recipient filling out for another) + */ + isAssistantMode: boolean; +} + +const DocumentSigningRecipientContext = createContext( + null, +); + +export interface DocumentSigningRecipientProviderProps extends PropsWithChildren { + recipient: Recipient | RecipientWithFields; + targetSigner?: RecipientWithFields | null; +} + +export const DocumentSigningRecipientProvider = ({ + children, + recipient, + targetSigner = null, +}: DocumentSigningRecipientProviderProps) => { + // console.log({ + // recipient, + // targetSigner, + // isAssistantMode: !!targetSigner, + // }); + return ( + + {children} + + ); +}; + +export function useDocumentSigningRecipientContext() { + const context = useContext(DocumentSigningRecipientContext); + + if (!context) { + throw new Error('useDocumentSigningRecipientContext must be used within a RecipientProvider'); + } + + return context; +} diff --git a/apps/web/src/app/(signing)/sign/[token]/reject-document-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx similarity index 85% rename from apps/web/src/app/(signing)/sign/[token]/reject-document-dialog.tsx rename to apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx index 547a346d8..6fd88824a 100644 --- a/apps/web/src/app/(signing)/sign/[token]/reject-document-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx @@ -1,15 +1,14 @@ -'use client'; - import { useEffect, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import type { Document } from '@prisma/client'; import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; +import { useSearchParams } from 'react-router'; import { z } from 'zod'; -import type { Document } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -40,15 +39,20 @@ const ZRejectDocumentFormSchema = z.object({ type TRejectDocumentFormSchema = z.infer; -export interface RejectDocumentDialogProps { +export interface DocumentSigningRejectDialogProps { document: Pick; token: string; + onRejected?: (reason: string) => void | Promise; } -export function RejectDocumentDialog({ document, token }: RejectDocumentDialogProps) { +export function DocumentSigningRejectDialog({ + document, + token, + onRejected, +}: DocumentSigningRejectDialogProps) { const { toast } = useToast(); - const router = useRouter(); - const searchParams = useSearchParams(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [isOpen, setIsOpen] = useState(false); @@ -64,7 +68,6 @@ export function RejectDocumentDialog({ document, token }: RejectDocumentDialogPr const onRejectDocument = async ({ reason }: TRejectDocumentFormSchema) => { try { - // TODO: Add trpc mutation here await rejectDocumentWithToken({ documentId: document.id, token, @@ -79,7 +82,11 @@ export function RejectDocumentDialog({ document, token }: RejectDocumentDialogPr setIsOpen(false); - router.push(`/sign/${token}/rejected`); + if (onRejected) { + await onRejected(reason); + } else { + await navigate(`/sign/${token}/rejected`); + } } catch (err) { toast({ title: 'Error', diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx similarity index 87% rename from apps/web/src/app/(signing)/sign/[token]/signature-field.tsx rename to apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx index bba784975..1b1f92dbd 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx @@ -1,17 +1,14 @@ -'use client'; +import { useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { useLayoutEffect, useMemo, useRef, useState, useTransition } from 'react'; - -import { useRouter } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { Loader } from 'lucide-react'; +import { useRevalidator } from 'react-router'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; -import { type Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { @@ -24,32 +21,32 @@ import { Label } from '@documenso/ui/primitives/label'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { SigningDisclosure } from '~/components/general/signing-disclosure'; +import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure'; -import { useRequiredDocumentAuthContext } from './document-auth-provider'; -import { useRequiredSigningContext } from './provider'; -import { SigningFieldContainer } from './signing-field-container'; +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useRequiredDocumentSigningContext } from './document-signing-provider'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text'; -export type SignatureFieldProps = { +export type DocumentSigningSignatureFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; typedSignatureEnabled?: boolean; }; -export const SignatureField = ({ +export const DocumentSigningSignatureField = ({ field, - recipient, onSignField, onUnsignField, typedSignatureEnabled, -}: SignatureFieldProps) => { - const router = useRouter(); - +}: DocumentSigningSignatureFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { revalidate } = useRevalidator(); + + const { recipient } = useDocumentSigningRecipientContext(); const signatureRef = useRef(null); const containerRef = useRef(null); @@ -60,11 +57,9 @@ export const SignatureField = ({ setSignature: setProvidedSignature, signatureValid, setSignatureValid, - } = useRequiredSigningContext(); + } = useRequiredDocumentSigningContext(); - const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); - - const [isPending, startTransition] = useTransition(); + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); @@ -76,7 +71,7 @@ export const SignatureField = ({ const { signature } = field; - const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading; const [showSignatureModal, setShowSignatureModal] = useState(false); const [localSignature, setLocalSignature] = useState(null); @@ -148,12 +143,11 @@ export const SignatureField = ({ if (onSignField) { await onSignField(payload); - return; + } else { + await signFieldWithToken(payload); } - await signFieldWithToken(payload); - - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { const error = AppError.parseError(err); @@ -181,11 +175,11 @@ export const SignatureField = ({ if (onUnsignField) { await onUnsignField(payload); return; + } else { + await removeSignedFieldWithToken(payload); } - await removeSignedFieldWithToken(payload); - - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { console.error(err); @@ -233,7 +227,7 @@ export const SignatureField = ({ }, [signature?.typedSignature]); return ( - - + +
- + ); }; diff --git a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx similarity index 80% rename from apps/web/src/app/(signing)/sign/[token]/text-field.tsx rename to apps/remix/app/components/general/document-signing/document-signing-text-field.tsx index 0c4088d75..074628cc4 100644 --- a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx @@ -1,19 +1,16 @@ -'use client'; +import { useEffect, useState } from 'react'; -import { useEffect, useState, useTransition } from 'react'; - -import { useRouter } from 'next/navigation'; - -import { Plural, Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Plural, Trans } from '@lingui/react/macro'; import { Loader, Type } from 'lucide-react'; +import { useRevalidator } from 'react-router'; import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZTextFieldMeta } from '@documenso/lib/types/field-meta'; -import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import { trpc } from '@documenso/trpc/react'; import type { @@ -26,33 +23,46 @@ import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/ import { Textarea } from '@documenso/ui/primitives/textarea'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useRequiredDocumentAuthContext } from './document-auth-provider'; -import { SigningFieldContainer } from './signing-field-container'; +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; -export type TextFieldProps = { +export type DocumentSigningTextFieldProps = { field: FieldWithSignatureAndFieldMeta; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => { +type ValidationErrors = { + required: string[]; + characterLimit: string[]; +}; + +export type TextFieldProps = { + field: FieldWithSignatureAndFieldMeta; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +export const DocumentSigningTextField = ({ + field, + onSignField, + onUnsignField, +}: DocumentSigningTextFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { revalidate } = useRevalidator(); - const router = useRouter(); + const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); - const initialErrors: Record = { + const initialErrors: ValidationErrors = { required: [], characterLimit: [], }; - const [errors, setErrors] = useState(initialErrors); const userInputHasErrors = Object.values(errors).some((error) => error.length > 0); - const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); - - const [isPending, startTransition] = useTransition(); + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); @@ -62,9 +72,10 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text isPending: isRemoveSignedFieldWithTokenLoading, } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); - const parsedFieldMeta = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null; + const safeFieldMeta = ZTextFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; - const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading; const shouldAutoSignField = (!field.inserted && parsedFieldMeta?.text) || (!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly); @@ -153,7 +164,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text setLocalCustomText(''); - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { const error = AppError.parseError(err); @@ -165,7 +176,9 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -187,13 +200,13 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text setLocalCustomText(parsedFieldMeta?.text ?? ''); - startTransition(() => router.refresh()); + await revalidate(); } catch (err) { console.error(err); toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the text.`), + description: _(msg`An error occurred while removing the field.`), variant: 'destructive', }); } @@ -228,12 +241,12 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0); return ( - {isLoading && (
@@ -261,11 +274,23 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text )} {field.inserted && ( -

- {field.customText.length < 20 - ? field.customText - : field.customText.substring(0, 15) + '...'} -

+
+

+ {field.customText.length < 20 + ? field.customText + : field.customText.substring(0, 20) + '...'} +

+
)} @@ -281,6 +306,10 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text className={cn('mt-2 w-full rounded-md', { 'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200': userInputHasErrors, + 'text-left': parsedFieldMeta?.textAlign === 'left', + 'text-center': + !parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center', + 'text-right': parsedFieldMeta?.textAlign === 'right', })} value={localText} onChange={handleTextChange} @@ -347,6 +376,6 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text - + ); }; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx similarity index 88% rename from apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx rename to apps/remix/app/components/general/document/document-audit-log-download-button.tsx index d6be5318c..fb531eb37 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx +++ b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx @@ -1,7 +1,6 @@ -'use client'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { DownloadIcon } from 'lucide-react'; import { trpc } from '@documenso/trpc/react'; @@ -9,13 +8,15 @@ import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type DownloadAuditLogButtonProps = { +export type DocumentAuditLogDownloadButtonProps = { className?: string; - teamId?: number; documentId: number; }; -export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => { +export const DocumentAuditLogDownloadButton = ({ + className, + documentId, +}: DocumentAuditLogDownloadButtonProps) => { const { toast } = useToast(); const { _ } = useLingui(); diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx b/apps/remix/app/components/general/document/document-certificate-download-button.tsx similarity index 83% rename from apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx rename to apps/remix/app/components/general/document/document-certificate-download-button.tsx index 18eff7258..7584fe81b 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx +++ b/apps/remix/app/components/general/document/document-certificate-download-button.tsx @@ -1,28 +1,26 @@ -'use client'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { DocumentStatus } from '@prisma/client'; import { DownloadIcon } from 'lucide-react'; -import { DocumentStatus } from '@documenso/prisma/client'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type DownloadCertificateButtonProps = { +export type DocumentCertificateDownloadButtonProps = { className?: string; documentId: number; documentStatus: DocumentStatus; - teamId?: number; }; -export const DownloadCertificateButton = ({ +export const DocumentCertificateDownloadButton = ({ className, documentId, documentStatus, - teamId, -}: DownloadCertificateButtonProps) => { +}: DocumentCertificateDownloadButtonProps) => { const { toast } = useToast(); const { _ } = useLingui(); @@ -79,7 +77,7 @@ export const DownloadCertificateButton = ({ className={cn('w-full sm:w-auto', className)} loading={isPending} variant="outline" - disabled={documentStatus !== DocumentStatus.COMPLETED} + disabled={!isDocumentCompleted(documentStatus)} onClick={() => void onDownloadCertificatesClick()} > {!isPending && } diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx similarity index 89% rename from apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx rename to apps/remix/app/components/general/document/document-edit-form.tsx index 7977aa2c6..58bebdf38 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -1,11 +1,9 @@ -'use client'; - import { useEffect, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; - -import { msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client'; +import { useNavigate, useSearchParams } from 'react-router'; import { isValidLanguageCode } from '@documenso/lib/constants/i18n'; import { @@ -13,7 +11,6 @@ import { SKIP_QUERY_BATCH_META, } from '@documenso/lib/constants/trpc'; import type { TDocument } from '@documenso/lib/types/document'; -import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -27,13 +24,13 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; -import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { Stepper } from '@documenso/ui/primitives/stepper'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { useOptionalCurrentTeam } from '~/providers/team'; -export type EditDocumentFormProps = { +export type DocumentEditFormProps = { className?: string; initialDocument: TDocument; documentRootPath: string; @@ -43,17 +40,18 @@ export type EditDocumentFormProps = { type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject'; const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields', 'subject']; -export const EditDocumentForm = ({ +export const DocumentEditForm = ({ className, initialDocument, documentRootPath, isDocumentEnterprise, -}: EditDocumentFormProps) => { +}: DocumentEditFormProps) => { const { toast } = useToast(); const { _ } = useLingui(); - const router = useRouter(); - const searchParams = useSearchParams(); + const navigate = useNavigate(); + + const [searchParams] = useSearchParams(); const team = useOptionalCurrentTeam(); const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); @@ -134,9 +132,6 @@ export const EditDocumentForm = ({ }, }); - const { mutateAsync: setPasswordForDocument } = - trpc.document.setPasswordForDocument.useMutation(); - const documentFlow: Record = { settings: { title: msg`General`, @@ -198,9 +193,6 @@ export const EditDocumentForm = ({ }, }); - // Router refresh is here to clear the router cache for when navigating to /documents. - router.refresh(); - setStep('signers'); } catch (err) { console.error(err); @@ -231,9 +223,6 @@ export const EditDocumentForm = ({ }), ]); - // Router refresh is here to clear the router cache for when navigating to /documents. - router.refresh(); - setStep('fields'); } catch (err) { console.error(err); @@ -269,9 +258,6 @@ export const EditDocumentForm = ({ } } - // Router refresh is here to clear the router cache for when navigating to /documents. - router.refresh(); - setStep('subject'); } catch (err) { console.error(err); @@ -305,18 +291,15 @@ export const EditDocumentForm = ({ duration: 5000, }); - router.push(documentRootPath); - return; - } - - if (document.status === DocumentStatus.DRAFT) { + await navigate(documentRootPath); + } else if (document.status === DocumentStatus.DRAFT) { toast({ title: _(msg`Links Generated`), description: _(msg`Signing links have been generated for this document.`), duration: 5000, }); } else { - router.push(`${documentRootPath}/${document.id}`); + await navigate(`${documentRootPath}/${document.id}`); } } catch (err) { console.error(err); @@ -329,13 +312,6 @@ export const EditDocumentForm = ({ } }; - const onPasswordSubmit = async (password: string) => { - await setPasswordForDocument({ - documentId: document.id, - password, - }); - }; - const currentDocumentFlow = documentFlow[step]; /** @@ -354,12 +330,10 @@ export const EditDocumentForm = ({ gradient > - setIsDocumentPdfLoaded(true)} /> diff --git a/apps/web/src/components/document/document-history-sheet-changes.tsx b/apps/remix/app/components/general/document/document-history-sheet-changes.tsx similarity index 97% rename from apps/web/src/components/document/document-history-sheet-changes.tsx rename to apps/remix/app/components/general/document/document-history-sheet-changes.tsx index ef3985a61..577dbc473 100644 --- a/apps/web/src/components/document/document-history-sheet-changes.tsx +++ b/apps/remix/app/components/general/document/document-history-sheet-changes.tsx @@ -1,5 +1,3 @@ -'use client'; - import React from 'react'; import { Badge } from '@documenso/ui/primitives/badge'; diff --git a/apps/web/src/components/document/document-history-sheet.tsx b/apps/remix/app/components/general/document/document-history-sheet.tsx similarity index 96% rename from apps/web/src/components/document/document-history-sheet.tsx rename to apps/remix/app/components/general/document/document-history-sheet.tsx index 8bda3a424..ef73c1c8f 100644 --- a/apps/web/src/components/document/document-history-sheet.tsx +++ b/apps/remix/app/components/general/document/document-history-sheet.tsx @@ -1,9 +1,7 @@ -'use client'; - import { useMemo, useState } from 'react'; -import { Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { ArrowRightIcon, Loader } from 'lucide-react'; import { DateTime } from 'luxon'; import { match } from 'ts-pattern'; @@ -353,6 +351,16 @@ export const DocumentHistorySheet = ({ /> ), ) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, ({ data }) => ( + + )) .exhaustive()} {isUserDetailsVisible && ( diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx b/apps/remix/app/components/general/document/document-page-view-button.tsx similarity index 83% rename from apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx rename to apps/remix/app/components/general/document/document-page-view-button.tsx index a477d75c6..97e797976 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx +++ b/apps/remix/app/components/general/document/document-page-view-button.tsx @@ -1,17 +1,16 @@ -'use client'; - -import Link from 'next/link'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { Document, Recipient, Team, User } from '@prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react'; -import { useSession } from 'next-auth/react'; +import { Link } from 'react-router'; import { match } from 'ts-pattern'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; -import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; -import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { Button } from '@documenso/ui/primitives/button'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -22,23 +21,19 @@ export type DocumentPageViewButtonProps = { recipients: Recipient[]; team: Pick | null; }; - team?: Pick; }; export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps) => { - const { data: session } = useSession(); + const { user } = useSession(); + const { toast } = useToast(); const { _ } = useLingui(); - if (!session) { - return null; - } - - const recipient = document.recipients.find((recipient) => recipient.email === session.user.email); + const recipient = document.recipients.find((recipient) => recipient.email === user.email); const isRecipient = !!recipient; const isPending = document.status === DocumentStatus.PENDING; - const isComplete = document.status === DocumentStatus.COMPLETED; + const isComplete = isDocumentCompleted(document); const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const role = recipient?.role; @@ -81,7 +76,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps }) .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx similarity index 75% rename from apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx rename to apps/remix/app/components/general/document/document-page-view-dropdown.tsx index 5075f342c..21f4ce481 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx +++ b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx @@ -1,11 +1,10 @@ -'use client'; - import { useState } from 'react'; -import Link from 'next/link'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { DocumentStatus } from '@prisma/client'; +import type { Document, Recipient, Team, User } from '@prisma/client'; import { Copy, Download, @@ -16,12 +15,13 @@ import { Share, Trash2, } from 'lucide-react'; -import { useSession } from 'next-auth/react'; +import { Link } from 'react-router'; +import { useNavigate } from 'react-router'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; -import { DocumentStatus } from '@documenso/prisma/client'; -import type { Document, Recipient, Team, TeamEmail, User } from '@documenso/prisma/client'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { @@ -33,11 +33,11 @@ import { } from '@documenso/ui/primitives/dropdown-menu'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog'; - -import { ResendDocumentActionItem } from '../_action-items/resend-document'; -import { DeleteDocumentDialog } from '../delete-document-dialog'; -import { DuplicateDocumentDialog } from '../duplicate-document-dialog'; +import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog'; +import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog'; +import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog'; +import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog'; +import { useOptionalCurrentTeam } from '~/providers/team'; export type DocumentPageViewDropdownProps = { document: Document & { @@ -45,28 +45,26 @@ export type DocumentPageViewDropdownProps = { recipients: Recipient[]; team: Pick | null; }; - team?: Pick & { teamEmail: TeamEmail | null }; }; -export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => { - const { data: session } = useSession(); +export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownProps) => { + const { user } = useSession(); const { toast } = useToast(); const { _ } = useLingui(); + const navigate = useNavigate(); + const team = useOptionalCurrentTeam(); + const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); - if (!session) { - return null; - } + const recipient = document.recipients.find((recipient) => recipient.email === user.email); - const recipient = document.recipients.find((recipient) => recipient.email === session.user.email); - - const isOwner = document.user.id === session.user.id; + const isOwner = document.user.id === user.id; const isDraft = document.status === DocumentStatus.DRAFT; const isPending = document.status === DocumentStatus.PENDING; const isDeleted = document.deletedAt !== null; - const isComplete = document.status === DocumentStatus.COMPLETED; + const isComplete = isDocumentCompleted(document); const isCurrentTeamDocument = team && document.team?.url === team.url; const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); @@ -116,7 +114,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro {(isOwner || isCurrentTeamDocument) && !isComplete && ( - + Edit @@ -131,7 +129,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro )} - + Audit Log @@ -142,10 +140,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro Duplicate - setDeleteDialogOpen(true)} - disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted} - > + setDeleteDialogOpen(true)} disabled={isDeleted}> Delete @@ -169,11 +164,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro /> )} - + - { + void navigate(documentsPath); + }} /> {isDuplicateDialogOpen && ( - )} diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx b/apps/remix/app/components/general/document/document-page-view-information.tsx similarity index 92% rename from apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx rename to apps/remix/app/components/general/document/document-page-view-information.tsx index ebb6482d5..6ca4d784c 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx +++ b/apps/remix/app/components/general/document/document-page-view-information.tsx @@ -1,13 +1,12 @@ -'use client'; - import { useMemo } from 'react'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { Document, Recipient, User } from '@prisma/client'; import { DateTime } from 'luxon'; import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; -import type { Document, Recipient, User } from '@documenso/prisma/client'; export type DocumentPageViewInformationProps = { userId: number; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx b/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx similarity index 98% rename from apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx rename to apps/remix/app/components/general/document/document-page-view-recent-activity.tsx index c6e0787bb..10beae93b 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx +++ b/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx @@ -1,9 +1,8 @@ -'use client'; - import { useMemo } from 'react'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { AlertTriangle, CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react'; import { DateTime } from 'luxon'; import { match } from 'ts-pattern'; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx b/apps/remix/app/components/general/document/document-page-view-recipients.tsx similarity index 89% rename from apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx rename to apps/remix/app/components/general/document/document-page-view-recipients.tsx index ea8ccee15..2e413a8ad 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx +++ b/apps/remix/app/components/general/document/document-page-view-recipients.tsx @@ -1,9 +1,8 @@ -'use client'; - -import Link from 'next/link'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; +import type { Document, Recipient } from '@prisma/client'; import { AlertTriangle, CheckIcon, @@ -12,13 +11,14 @@ import { MailOpenIcon, PenIcon, PlusIcon, + UserIcon, } from 'lucide-react'; +import { Link } from 'react-router'; import { match } from 'ts-pattern'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatSigningLink } from '@documenso/lib/utils/recipients'; -import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; -import type { Document, Recipient } from '@documenso/prisma/client'; import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; import { SignatureIcon } from '@documenso/ui/icons/signature'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; @@ -49,9 +49,9 @@ export const DocumentPageViewRecipients = ({ Recipients

- {document.status !== DocumentStatus.COMPLETED && ( + {!isDocumentCompleted(document.status) && ( @@ -120,6 +120,12 @@ export const DocumentPageViewRecipients = ({ Viewed )) + .with(RecipientRole.ASSISTANT, () => ( + <> + + Assisted + + )) .exhaustive()} )} diff --git a/apps/web/src/components/document/document-read-only-fields.tsx b/apps/remix/app/components/general/document/document-read-only-fields.tsx similarity index 97% rename from apps/web/src/components/document/document-read-only-fields.tsx rename to apps/remix/app/components/general/document/document-read-only-fields.tsx index 926ddaa9d..6970a88be 100644 --- a/apps/web/src/components/document/document-read-only-fields.tsx +++ b/apps/remix/app/components/general/document/document-read-only-fields.tsx @@ -1,9 +1,9 @@ -'use client'; - import { useState } from 'react'; -import { Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { DocumentMeta } from '@prisma/client'; +import { FieldType, SigningStatus } from '@prisma/client'; import { Clock, EyeOffIcon } from 'lucide-react'; import { P, match } from 'ts-pattern'; @@ -16,8 +16,6 @@ import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones' import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document'; import { parseMessageDescriptor } from '@documenso/lib/utils/i18n'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; -import type { DocumentMeta } from '@documenso/prisma/client'; -import { FieldType, SigningStatus } from '@documenso/prisma/client'; import { FieldRootContainer } from '@documenso/ui/components/field/field'; import { SignatureIcon } from '@documenso/ui/icons/signature'; import { cn } from '@documenso/ui/lib/utils'; diff --git a/apps/web/src/components/document/document-recipient-link-copy-dialog.tsx b/apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx similarity index 93% rename from apps/web/src/components/document/document-recipient-link-copy-dialog.tsx rename to apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx index bec368f4c..c347a7e94 100644 --- a/apps/web/src/components/document/document-recipient-link-copy-dialog.tsx +++ b/apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx @@ -1,19 +1,17 @@ -'use client'; - import { useEffect, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { Recipient } from '@prisma/client'; +import { RecipientRole } from '@prisma/client'; +import { useSearchParams } from 'react-router'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { formatSigningLink } from '@documenso/lib/utils/recipients'; -import type { Recipient } from '@documenso/prisma/client'; -import { RecipientRole } from '@documenso/prisma/client'; import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { Button } from '@documenso/ui/primitives/button'; @@ -43,7 +41,7 @@ export const DocumentRecipientLinkCopyDialog = ({ const [, copy] = useCopyToClipboard(); - const searchParams = useSearchParams(); + const [searchParams] = useSearchParams(); const updateSearchParams = useUpdateSearchParams(); const [open, setOpen] = useState(false); @@ -69,7 +67,7 @@ export const DocumentRecipientLinkCopyDialog = ({ setOpen(true); updateSearchParams({ action: null }); } - }, [actionSearchParam, open, setOpen, updateSearchParams]); + }, [actionSearchParam, open]); return ( setOpen(value)}> diff --git a/apps/web/src/components/(dashboard)/document-search/document-search.tsx b/apps/remix/app/components/general/document/document-search.tsx similarity index 74% rename from apps/web/src/components/(dashboard)/document-search/document-search.tsx rename to apps/remix/app/components/general/document/document-search.tsx index 966452152..dac2ad542 100644 --- a/apps/web/src/components/(dashboard)/document-search/document-search.tsx +++ b/apps/remix/app/components/general/document/document-search.tsx @@ -1,11 +1,8 @@ -'use client'; - import { useCallback, useEffect, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; - -import { msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { useSearchParams } from 'react-router'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { Input } from '@documenso/ui/primitives/input'; @@ -13,8 +10,7 @@ import { Input } from '@documenso/ui/primitives/input'; export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => { const { _ } = useLingui(); - const router = useRouter(); - const searchParams = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const [searchTerm, setSearchTerm] = useState(initialValue); const debouncedSearchTerm = useDebouncedValue(searchTerm, 500); @@ -23,13 +19,14 @@ export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) (term: string) => { const params = new URLSearchParams(searchParams?.toString() ?? ''); if (term) { - params.set('search', term); + params.set('query', term); } else { - params.delete('search'); + params.delete('query'); } - router.push(`?${params.toString()}`); + + setSearchParams(params); }, - [router, searchParams], + [searchParams], ); useEffect(() => { diff --git a/apps/web/src/components/formatter/document-status.tsx b/apps/remix/app/components/general/document/document-status.tsx similarity index 88% rename from apps/web/src/components/formatter/document-status.tsx rename to apps/remix/app/components/general/document/document-status.tsx index 494a9b627..1a24f47f2 100644 --- a/apps/web/src/components/formatter/document-status.tsx +++ b/apps/remix/app/components/general/document/document-status.tsx @@ -1,9 +1,9 @@ import type { HTMLAttributes } from 'react'; import type { MessageDescriptor } from '@lingui/core'; -import { msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { CheckCircle2, Clock, File } from 'lucide-react'; +import { CheckCircle2, Clock, File, XCircle } from 'lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; @@ -36,6 +36,12 @@ export const FRIENDLY_STATUS_MAP: Record icon: File, color: 'text-yellow-500 dark:text-yellow-200', }, + REJECTED: { + label: msg`Rejected`, + labelExtended: msg`Document rejected`, + icon: XCircle, + color: 'text-red-500 dark:text-red-300', + }, INBOX: { label: msg`Inbox`, labelExtended: msg`Document inbox`, diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/remix/app/components/general/document/document-upload.tsx similarity index 81% rename from apps/web/src/app/(dashboard)/documents/upload-document.tsx rename to apps/remix/app/components/general/document/document-upload.tsx index c8fc800a0..741b6da02 100644 --- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx +++ b/apps/remix/app/components/general/document/document-upload.tsx @@ -1,21 +1,18 @@ -'use client'; - import { useMemo, useState } from 'react'; -import { useRouter } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { Loader } from 'lucide-react'; -import { useSession } from 'next-auth/react'; +import { useNavigate } from 'react-router'; import { match } from 'ts-pattern'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; +import { useSession } from '@documenso/lib/client-only/providers/session'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; @@ -23,26 +20,26 @@ import { cn } from '@documenso/ui/lib/utils'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type UploadDocumentProps = { +import { useOptionalCurrentTeam } from '~/providers/team'; + +export type DocumentUploadDropzoneProps = { className?: string; - team?: { - id: number; - url: string; - }; }; -export const UploadDocument = ({ className, team }: UploadDocumentProps) => { - const router = useRouter(); +export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + const { user } = useSession(); + + const team = useOptionalCurrentTeam(); + + const navigate = useNavigate(); const analytics = useAnalytics(); + const userTimezone = TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ?? DEFAULT_DOCUMENT_TIME_ZONE; - const { data: session } = useSession(); - - const { _ } = useLingui(); - const { toast } = useToast(); - const { quota, remaining, refreshLimits } = useLimits(); const [isLoading, setIsLoading] = useState(false); @@ -56,26 +53,21 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => { : msg`You have reached your document limit.`; } - if (!session?.user.emailVerified) { + if (!user.emailVerified) { return msg`Verify your email to upload documents.`; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [remaining.documents, session?.user.emailVerified, team]); + }, [remaining.documents, user.emailVerified, team]); const onFileDrop = async (file: File) => { try { setIsLoading(true); - const { type, data } = await putPdfFile(file); - - const { id: documentDataId } = await createDocumentData({ - type, - data, - }); + const response = await putPdfFile(file); const { id } = await createDocument({ title: file.name, - documentDataId, + documentDataId: response.id, timezone: userTimezone, }); @@ -88,12 +80,12 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => { }); analytics.capture('App: Document Uploaded', { - userId: session?.user.id, + userId: user.id, documentId: id, timestamp: new Date().toISOString(), }); - router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`); + await navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`); } catch (err) { const error = AppError.parseError(err); @@ -131,7 +123,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
; + +export type GenericErrorLayoutProps = { + children?: React.ReactNode; + errorCode?: number; + errorCodeMap?: ErrorCodeMap; + /** + * The primary button to display. If left as undefined, the default /documents link will be displayed. + * + * Set to null if you want no button. + */ + primaryButton?: React.ReactNode | null; + + /** + * The secondary button to display. If left as undefined, the default back button will be displayed. + * + * Set to null if you want no button. + */ + secondaryButton?: React.ReactNode | null; +}; + +export const defaultErrorCodeMap: ErrorCodeMap = { + 404: { + subHeading: msg`404 not found`, + heading: msg`Oops! Something went wrong.`, + message: msg`The page you are looking for was moved, removed, renamed or might never have existed.`, + }, + 500: { + subHeading: msg`500 Internal Server Error`, + heading: msg`Oops! Something went wrong.`, + message: msg`An unexpected error occurred.`, + }, +}; + +export const GenericErrorLayout = ({ + children, + errorCode, + errorCodeMap = defaultErrorCodeMap, + primaryButton, + secondaryButton, +}: GenericErrorLayoutProps) => { + const navigate = useNavigate(); + const { _ } = useLingui(); + + const team = useOptionalCurrentTeam(); + + const { subHeading, heading, message } = + errorCodeMap[errorCode || 500] ?? defaultErrorCodeMap[500]; + + return ( +
+
+ + background pattern + +
+ +
+
+

{_(subHeading)}

+ +

{_(heading)}

+ +

{_(message)}

+ +
+ {secondaryButton || + (secondaryButton !== null && ( + + ))} + + {primaryButton || + (primaryButton !== null && ( + + ))} + + {children} +
+
+
+
+ ); +}; diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/remix/app/components/general/menu-switcher.tsx similarity index 85% rename from apps/web/src/components/(dashboard)/layout/menu-switcher.tsx rename to apps/remix/app/components/general/menu-switcher.tsx index 6731845fc..c8ae7d719 100644 --- a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx +++ b/apps/remix/app/components/general/menu-switcher.tsx @@ -1,23 +1,20 @@ -'use client'; - import { useState } from 'react'; -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { motion } from 'framer-motion'; import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react'; -import { signOut } from 'next-auth/react'; +import { Link, useLocation } from 'react-router'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { authClient } from '@documenso/auth/client'; +import type { SessionUser } from '@documenso/auth/server/lib/session/session'; import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams'; -import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { isAdmin } from '@documenso/lib/utils/is-admin'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; -import type { User } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog'; import { cn } from '@documenso/ui/lib/utils'; @@ -35,14 +32,14 @@ import { const MotionLink = motion(Link); export type MenuSwitcherProps = { - user: User; + user: SessionUser; teams: TGetTeamsResponse; }; export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProps) => { const { _ } = useLingui(); - const pathname = usePathname(); + const { pathname } = useLocation(); const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false); @@ -89,12 +86,12 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp * seemlessly between teams and personal accounts. */ const formatRedirectUrlOnSwitch = (teamUrl?: string) => { - const baseUrl = teamUrl ? `/t/${teamUrl}/` : '/'; + const baseUrl = teamUrl ? `/t/${teamUrl}` : ''; const currentPathname = (pathname ?? '/').replace(TEAM_URL_REGEX, ''); if (currentPathname === '/templates') { - return `${baseUrl}templates`; + return `${baseUrl}/templates`; } return baseUrl; @@ -109,9 +106,9 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2" > - + - + @@ -183,7 +176,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0" asChild > - + @@ -199,14 +192,10 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp initial="initial" animate="initial" whileHover="animate" - href={formatRedirectUrlOnSwitch(team.url)} + to={formatRedirectUrlOnSwitch(team.url)} > Create team @@ -258,14 +247,14 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp {isUserAdmin && ( - + Admin panel )} - + User settings @@ -273,7 +262,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp {selectedTeam && canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && ( - + Team settings @@ -288,11 +277,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp - signOut({ - callbackUrl: '/', - }) - } + onSelect={async () => authClient.signOut()} > Sign Out diff --git a/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx b/apps/remix/app/components/general/metric-card.tsx similarity index 100% rename from apps/web/src/components/(dashboard)/metric-card/metric-card.tsx rename to apps/remix/app/components/general/metric-card.tsx diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx b/apps/remix/app/components/general/multiselect-role-combobox.tsx similarity index 96% rename from apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx rename to apps/remix/app/components/general/multiselect-role-combobox.tsx index bf7f85d72..f69a9c616 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx +++ b/apps/remix/app/components/general/multiselect-role-combobox.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; -import { Trans } from '@lingui/macro'; +import { Trans } from '@lingui/react/macro'; +import { Role } from '@prisma/client'; import { Check, ChevronsUpDown } from 'lucide-react'; -import { Role } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { diff --git a/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx b/apps/remix/app/components/general/period-selector.tsx similarity index 67% rename from apps/web/src/components/(dashboard)/period-selector/period-selector.tsx rename to apps/remix/app/components/general/period-selector.tsx index 94285c138..025925a92 100644 --- a/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx +++ b/apps/remix/app/components/general/period-selector.tsx @@ -1,11 +1,9 @@ -'use client'; - import { useMemo } from 'react'; -import { usePathname, useRouter, useSearchParams } from 'next/navigation'; - -import { Trans } from '@lingui/macro'; +import { Trans } from '@lingui/react/macro'; +import { useLocation, useNavigate, useSearchParams } from 'react-router'; +import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; import { Select, SelectContent, @@ -14,13 +12,16 @@ import { SelectValue, } from '@documenso/ui/primitives/select'; -import { isPeriodSelectorValue } from './types'; +const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return ['', '7d', '14d', '30d'].includes(value as string); +}; export const PeriodSelector = () => { - const pathname = usePathname(); - const searchParams = useSearchParams(); + const { pathname } = useLocation(); + const [searchParams] = useSearchParams(); - const router = useRouter(); + const navigate = useNavigate(); const period = useMemo(() => { const p = searchParams?.get('period') ?? 'all'; @@ -41,7 +42,7 @@ export const PeriodSelector = () => { params.delete('period'); } - router.push(`${pathname}?${params.toString()}`, { scroll: false }); + void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true }); }; return ( diff --git a/apps/remix/app/components/general/portal.tsx b/apps/remix/app/components/general/portal.tsx new file mode 100644 index 000000000..a8e0c9eea --- /dev/null +++ b/apps/remix/app/components/general/portal.tsx @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +import { createPortal } from 'react-dom'; + +type PortalComponentProps = { + children: React.ReactNode; + target: string; +}; + +export const PortalComponent = ({ children, target }: PortalComponentProps) => { + const [portalRoot, setPortalRoot] = useState(null); + + useEffect(() => { + setPortalRoot(document.getElementById(target)); + }, [target]); + + return portalRoot ? createPortal(children, portalRoot) : null; +}; diff --git a/apps/web/src/components/(dashboard)/refresh-on-focus/refresh-on-focus.tsx b/apps/remix/app/components/general/refresh-on-focus.tsx similarity index 52% rename from apps/web/src/components/(dashboard)/refresh-on-focus/refresh-on-focus.tsx rename to apps/remix/app/components/general/refresh-on-focus.tsx index 1b2f529b8..bf8f7a68a 100644 --- a/apps/web/src/components/(dashboard)/refresh-on-focus/refresh-on-focus.tsx +++ b/apps/remix/app/components/general/refresh-on-focus.tsx @@ -1,15 +1,18 @@ -'use client'; - import { useCallback, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRevalidator } from 'react-router'; +/** + * Not really used anymore, this causes random 500s when the user refreshes while this occurs. + */ export const RefreshOnFocus = () => { - const { refresh } = useRouter(); + const { revalidate, state } = useRevalidator(); const onFocus = useCallback(() => { - refresh(); - }, [refresh]); + if (state === 'idle') { + void revalidate(); + } + }, [revalidate]); useEffect(() => { window.addEventListener('focus', onFocus); diff --git a/apps/web/src/components/(dashboard)/settings/layout/header.tsx b/apps/remix/app/components/general/settings-header.tsx similarity index 90% rename from apps/web/src/components/(dashboard)/settings/layout/header.tsx rename to apps/remix/app/components/general/settings-header.tsx index 6f5ae28bc..06de2ff24 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/header.tsx +++ b/apps/remix/app/components/general/settings-header.tsx @@ -3,8 +3,8 @@ import React from 'react'; import { cn } from '@documenso/ui/lib/utils'; export type SettingsHeaderProps = { - title: string; - subtitle: string; + title: string | React.ReactNode; + subtitle: string | React.ReactNode; hideDivider?: boolean; children?: React.ReactNode; className?: string; diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/remix/app/components/general/settings-nav-desktop.tsx similarity index 65% rename from apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx rename to apps/remix/app/components/general/settings-nav-desktop.tsx index 43b1ef988..704637ff3 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/remix/app/components/general/settings-nav-desktop.tsx @@ -1,30 +1,24 @@ -'use client'; - import type { HTMLAttributes } from 'react'; -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -import { Trans } from '@lingui/macro'; +import { Trans } from '@lingui/react/macro'; import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react'; +import { useLocation } from 'react-router'; +import { Link } from 'react-router'; -import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -export type DesktopNavProps = HTMLAttributes; +export type SettingsDesktopNavProps = HTMLAttributes; -export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { - const pathname = usePathname(); +export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavProps) => { + const { pathname } = useLocation(); - const { getFlag } = useFeatureFlags(); - - const isBillingEnabled = getFlag('app_billing'); - const isPublicProfileEnabled = getFlag('app_public_profile'); + const isBillingEnabled = IS_BILLING_ENABLED(); return (
- + - {isPublicProfileEnabled && ( - - - - )} + + + - + - + - + - + - {isPublicProfileEnabled && ( - - - - )} + + + - + - + - + - +
-
+
); diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx b/apps/remix/app/components/general/stack-avatar.tsx similarity index 100% rename from apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx rename to apps/remix/app/components/general/stack-avatar.tsx diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx similarity index 97% rename from apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx rename to apps/remix/app/components/general/stack-avatars-with-tooltip.tsx index bccee558e..dd1659c3f 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ b/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx @@ -1,14 +1,12 @@ -'use client'; - import { useMemo } from 'react'; -import { Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { type DocumentStatus, type Recipient } from '@prisma/client'; import { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import { type DocumentStatus, type Recipient } from '@documenso/prisma/client'; import { PopoverHover } from '@documenso/ui/primitives/popover'; import { AvatarWithRecipient } from './avatar-with-recipient'; diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx b/apps/remix/app/components/general/stack-avatars.tsx similarity index 95% rename from apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx rename to apps/remix/app/components/general/stack-avatars.tsx index 95621c760..ccfd45bf1 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx +++ b/apps/remix/app/components/general/stack-avatars.tsx @@ -1,11 +1,12 @@ import React from 'react'; +import type { Recipient } from '@prisma/client'; + import { getExtraRecipientsType, getRecipientType, } from '@documenso/lib/client-only/recipient-type'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import type { Recipient } from '@documenso/prisma/client'; import { StackAvatar } from './stack-avatar'; diff --git a/apps/web/src/components/(teams)/team-billing-portal-button.tsx b/apps/remix/app/components/general/teams/team-billing-portal-button.tsx similarity index 93% rename from apps/web/src/components/(teams)/team-billing-portal-button.tsx rename to apps/remix/app/components/general/teams/team-billing-portal-button.tsx index 7ef4aad29..ce71fbc7e 100644 --- a/apps/web/src/components/(teams)/team-billing-portal-button.tsx +++ b/apps/remix/app/components/general/teams/team-billing-portal-button.tsx @@ -1,7 +1,6 @@ -'use client'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx b/apps/remix/app/components/general/teams/team-email-dropdown.tsx similarity index 85% rename from apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx rename to apps/remix/app/components/general/teams/team-email-dropdown.tsx index c1ae53a12..3c9be2dfb 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx +++ b/apps/remix/app/components/general/teams/team-email-dropdown.tsx @@ -1,7 +1,6 @@ -'use client'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react'; import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; @@ -14,14 +13,14 @@ import { } from '@documenso/ui/primitives/dropdown-menu'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { RemoveTeamEmailDialog } from '~/components/(teams)/dialogs/remove-team-email-dialog'; -import { UpdateTeamEmailDialog } from '~/components/(teams)/dialogs/update-team-email-dialog'; +import { TeamEmailDeleteDialog } from '~/components/dialogs/team-email-delete-dialog'; +import { TeamEmailUpdateDialog } from '~/components/dialogs/team-email-update-dialog'; -export type TeamsSettingsPageProps = { +export type TeamEmailDropdownProps = { team: Awaited>; }; -export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => { +export const TeamEmailDropdown = ({ team }: TeamEmailDropdownProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -69,7 +68,7 @@ export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => { )} {team.teamEmail && ( - e.preventDefault()}> @@ -80,7 +79,7 @@ export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => { /> )} - { const { data, isLoading } = trpc.team.getTeamInvitations.useQuery(); @@ -83,9 +82,7 @@ export const TeamInvitations = () => { {data.map((invitation) => (
  • { ); }; + +const AcceptTeamInvitationButton = ({ teamId }: { teamId: number }) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const { + mutateAsync: acceptTeamInvitation, + isPending, + isSuccess, + } = trpc.team.acceptTeamInvitation.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`Accepted team invitation`), + duration: 5000, + }); + }, + onError: () => { + toast({ + title: _(msg`Something went wrong`), + description: _(msg`Unable to join this team at this time.`), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + + ); +}; + +const DeclineTeamInvitationButton = ({ teamId }: { teamId: number }) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const { + mutateAsync: declineTeamInvitation, + isPending, + isSuccess, + } = trpc.team.declineTeamInvitation.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`Declined team invitation`), + duration: 5000, + }); + }, + onError: () => { + toast({ + title: _(msg`Something went wrong`), + description: _(msg`Unable to decline this team invitation at this time.`), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + + ); +}; diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx b/apps/remix/app/components/general/teams/team-layout-billing-banner.tsx similarity index 82% rename from apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx rename to apps/remix/app/components/general/teams/team-layout-billing-banner.tsx index d29b8abfb..68f013fac 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx +++ b/apps/remix/app/components/general/teams/team-layout-billing-banner.tsx @@ -1,15 +1,14 @@ -'use client'; - import { useState } from 'react'; -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { TeamMemberRole } from '@prisma/client'; +import { SubscriptionStatus } from '@prisma/client'; import { AlertTriangle } from 'lucide-react'; import { match } from 'ts-pattern'; import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; -import type { TeamMemberRole } from '@documenso/prisma/client'; -import { type Subscription, SubscriptionStatus } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -22,17 +21,17 @@ import { } from '@documenso/ui/primitives/dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type LayoutBillingBannerProps = { - subscription: Subscription; +export type TeamLayoutBillingBannerProps = { + subscriptionStatus: SubscriptionStatus; teamId: number; userRole: TeamMemberRole; }; -export const LayoutBillingBanner = ({ - subscription, +export const TeamLayoutBillingBanner = ({ + subscriptionStatus, teamId, userRole, -}: LayoutBillingBannerProps) => { +}: TeamLayoutBillingBannerProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -60,7 +59,7 @@ export const LayoutBillingBanner = ({ } }; - if (subscription.status === SubscriptionStatus.ACTIVE) { + if (subscriptionStatus === SubscriptionStatus.ACTIVE) { return null; } @@ -69,16 +68,16 @@ export const LayoutBillingBanner = ({
    - {match(subscription.status) + {match(subscriptionStatus) .with(SubscriptionStatus.PAST_DUE, () => Payment overdue) .with(SubscriptionStatus.INACTIVE, () => Teams restricted) .exhaustive()} @@ -88,9 +87,9 @@ export const LayoutBillingBanner = ({ variant="ghost" className={cn({ 'text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500': - subscription.status === SubscriptionStatus.PAST_DUE, + subscriptionStatus === SubscriptionStatus.PAST_DUE, 'text-destructive-foreground hover:bg-destructive-foreground hover:text-white': - subscription.status === SubscriptionStatus.INACTIVE, + subscriptionStatus === SubscriptionStatus.INACTIVE, })} disabled={isPending} onClick={() => setIsOpen(true)} @@ -107,7 +106,7 @@ export const LayoutBillingBanner = ({ Payment overdue - {match(subscription.status) + {match(subscriptionStatus) .with(SubscriptionStatus.PAST_DUE, () => ( diff --git a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx b/apps/remix/app/components/general/teams/team-settings-nav-desktop.tsx similarity index 70% rename from apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx rename to apps/remix/app/components/general/teams/team-settings-nav-desktop.tsx index beb31d9b1..e3d01bbaa 100644 --- a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx +++ b/apps/remix/app/components/general/teams/team-settings-nav-desktop.tsx @@ -1,28 +1,19 @@ -'use client'; - import type { HTMLAttributes } from 'react'; -import Link from 'next/link'; -import { useParams, usePathname } from 'next/navigation'; - -import { Trans } from '@lingui/macro'; +import { Trans } from '@lingui/react/macro'; import { Braces, CreditCard, Globe2Icon, Settings, Settings2, Users, Webhook } from 'lucide-react'; +import { Link, useLocation, useParams } from 'react-router'; -import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -export type DesktopNavProps = HTMLAttributes; +export type TeamSettingsNavDesktopProps = HTMLAttributes; -export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { - const pathname = usePathname(); +export const TeamSettingsNavDesktop = ({ className, ...props }: TeamSettingsNavDesktopProps) => { + const { pathname } = useLocation(); const params = useParams(); - const { getFlag } = useFeatureFlags(); - - const isPublicProfileEnabled = getFlag('app_public_profile'); - const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : ''; const settingsPath = `/t/${teamUrl}/settings`; @@ -35,7 +26,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { return (
    - + - + - {isPublicProfileEnabled && ( - - - - )} + + + - + - + - + - + - {isPublicProfileEnabled && ( - - - - )} + + + - + - + - +
    {match(document.source) diff --git a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-recipients.tsx b/apps/remix/app/components/general/template/template-page-view-recipients.tsx similarity index 89% rename from apps/web/src/app/(dashboard)/templates/[id]/template-page-view-recipients.tsx rename to apps/remix/app/components/general/template/template-page-view-recipients.tsx index ecbfa9a7f..0a65b3a09 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-recipients.tsx +++ b/apps/remix/app/components/general/template/template-page-view-recipients.tsx @@ -1,11 +1,11 @@ -import Link from 'next/link'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { Recipient, Template } from '@prisma/client'; import { PenIcon, PlusIcon } from 'lucide-react'; +import { Link } from 'react-router'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; -import type { Recipient, Template } from '@documenso/prisma/client'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; export type TemplatePageViewRecipientsProps = { @@ -31,7 +31,7 @@ export const TemplatePageViewRecipients = ({ diff --git a/apps/web/src/components/formatter/template-type.tsx b/apps/remix/app/components/general/template/template-type.tsx similarity index 91% rename from apps/web/src/components/formatter/template-type.tsx rename to apps/remix/app/components/general/template/template-type.tsx index 03a273470..f1d2ec244 100644 --- a/apps/web/src/components/formatter/template-type.tsx +++ b/apps/remix/app/components/general/template/template-type.tsx @@ -1,12 +1,12 @@ import type { HTMLAttributes } from 'react'; import type { MessageDescriptor } from '@lingui/core'; -import { msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import type { TemplateType as TemplateTypePrisma } from '@prisma/client'; import { Globe2, Lock } from 'lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react'; -import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; type TemplateTypeIcon = { diff --git a/apps/web/src/components/ui/user-profile-skeleton.tsx b/apps/remix/app/components/general/user-profile-skeleton.tsx similarity index 96% rename from apps/web/src/components/ui/user-profile-skeleton.tsx rename to apps/remix/app/components/general/user-profile-skeleton.tsx index 8c0fb1906..e4baacd9f 100644 --- a/apps/web/src/components/ui/user-profile-skeleton.tsx +++ b/apps/remix/app/components/general/user-profile-skeleton.tsx @@ -1,10 +1,8 @@ -'use client'; - -import { Trans } from '@lingui/macro'; +import { Trans } from '@lingui/react/macro'; +import type { User } from '@prisma/client'; import { File, User2 } from 'lucide-react'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; -import type { User } from '@documenso/prisma/client'; import { VerifiedIcon } from '@documenso/ui/icons/verified'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; diff --git a/apps/web/src/components/ui/user-profile-timur.tsx b/apps/remix/app/components/general/user-profile-timur.tsx similarity index 96% rename from apps/web/src/components/ui/user-profile-timur.tsx rename to apps/remix/app/components/general/user-profile-timur.tsx index ab2500018..f3484bc71 100644 --- a/apps/web/src/components/ui/user-profile-timur.tsx +++ b/apps/remix/app/components/general/user-profile-timur.tsx @@ -1,8 +1,4 @@ -'use client'; - -import Image from 'next/image'; - -import { Trans } from '@lingui/macro'; +import { Trans } from '@lingui/react/macro'; import { File } from 'lucide-react'; import timurImage from '@documenso/assets/images/timur.png'; @@ -31,7 +27,7 @@ export const UserProfileTimur = ({ className, rows = 2 }: UserProfileTimurProps)
    - image of timur ercan founder of documenso { const { toast } = useToast(); const [isOpen, setIsOpen] = useState(false); + const [isPending, setIsPending] = useState(false); const [isButtonDisabled, setIsButtonDisabled] = useState(false); - const { mutateAsync: sendConfirmationEmail, isPending } = - trpc.profile.sendConfirmationEmail.useMutation(); - const onResendConfirmationEmail = async () => { + if (isPending) { + return; + } + + setIsPending(true); + try { setIsButtonDisabled(true); - - await sendConfirmationEmail({ email: email }); + await authClient.emailPassword.resendVerifyEmail({ email: email }); toast({ title: _(msg`Success`), @@ -56,6 +58,8 @@ export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => { variant: 'destructive', }); } + + setIsPending(false); }; useEffect(() => { diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx b/apps/remix/app/components/general/webhook-multiselect-combobox.tsx similarity index 90% rename from apps/web/src/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx rename to apps/remix/app/components/general/webhook-multiselect-combobox.tsx index 5d5f2f682..d9f7bea62 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx +++ b/apps/remix/app/components/general/webhook-multiselect-combobox.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; -import { Plural, Trans } from '@lingui/macro'; -import { WebhookTriggerEvents } from '@prisma/client/'; +import { Plural, Trans } from '@lingui/react/macro'; +import { WebhookTriggerEvents } from '@prisma/client'; import { Check, ChevronsUpDown } from 'lucide-react'; import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name'; @@ -16,17 +16,17 @@ import { } from '@documenso/ui/primitives/command'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; -import { truncateTitle } from '~/helpers/truncate-title'; +import { truncateTitle } from '~/utils/truncate-title'; -type TriggerMultiSelectComboboxProps = { +type WebhookMultiSelectComboboxProps = { listValues: string[]; onChange: (_values: string[]) => void; }; -export const TriggerMultiSelectCombobox = ({ +export const WebhookMultiSelectCombobox = ({ listValues, onChange, -}: TriggerMultiSelectComboboxProps) => { +}: WebhookMultiSelectComboboxProps) => { const [isOpen, setIsOpen] = useState(false); const [selectedValues, setSelectedValues] = useState([]); diff --git a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx b/apps/remix/app/components/tables/admin-dashboard-users-table.tsx similarity index 92% rename from apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx rename to apps/remix/app/components/tables/admin-dashboard-users-table.tsx index 97a204e91..b5b7737f9 100644 --- a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx +++ b/apps/remix/app/components/tables/admin-dashboard-users-table.tsx @@ -1,16 +1,13 @@ -'use client'; - import { useEffect, useMemo, useState, useTransition } from 'react'; -import Link from 'next/link'; - -import { msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import type { Document, Role, Subscription } from '@prisma/client'; import { Edit, Loader } from 'lucide-react'; +import { Link } from 'react-router'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import type { Document, Role, Subscription } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table'; @@ -33,7 +30,7 @@ type SubscriptionLite = Pick< type DocumentLite = Pick; -type UsersDataTableProps = { +type AdminDashboardUsersTableProps = { users: UserData[]; totalPages: number; perPage: number; @@ -41,13 +38,13 @@ type UsersDataTableProps = { individualPriceIds: string[]; }; -export const UsersDataTable = ({ +export const AdminDashboardUsersTable = ({ users, totalPages, perPage, page, individualPriceIds, -}: UsersDataTableProps) => { +}: AdminDashboardUsersTableProps) => { const { _ } = useLingui(); const [isPending, startTransition] = useTransition(); @@ -101,7 +98,7 @@ export const UsersDataTable = ({ cell: ({ row }) => { return (
    ), accessorKey: 'name', @@ -80,7 +86,15 @@ export const LeaderboardTable = ({ onClick={() => handleColumnSort('signingVolume')} > {_(msg`Signing Volume`)} - + {sortBy === 'signingVolume' ? ( + sortOrder === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )}
    ), accessorKey: 'signingVolume', @@ -94,7 +108,15 @@ export const LeaderboardTable = ({ onClick={() => handleColumnSort('createdAt')} > {_(msg`Created`)} - + {sortBy === 'createdAt' ? ( + sortOrder === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )}
    ); }, @@ -102,7 +124,7 @@ export const LeaderboardTable = ({ cell: ({ row }) => i18n.date(row.original.createdAt), }, ] satisfies DataTableColumnDef[]; - }, [sortOrder]); + }, [sortOrder, sortBy]); useEffect(() => { startTransition(() => { @@ -133,6 +155,9 @@ export const LeaderboardTable = ({ const handleColumnSort = (column: 'name' | 'createdAt' | 'signingVolume') => { startTransition(() => { updateSearchParams({ + search: debouncedSearchString, + page, + perPage, sortBy: column, sortOrder: sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc', }); diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-data-table.tsx b/apps/remix/app/components/tables/document-logs-table.tsx similarity index 94% rename from apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-data-table.tsx rename to apps/remix/app/components/tables/document-logs-table.tsx index 45097b594..8cdae26d5 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-data-table.tsx +++ b/apps/remix/app/components/tables/document-logs-table.tsx @@ -1,13 +1,10 @@ -'use client'; - import { useMemo } from 'react'; -import { useSearchParams } from 'next/navigation'; - -import { msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { DateTime } from 'luxon'; import type { DateTimeFormatOptions } from 'luxon'; +import { useSearchParams } from 'react-router'; import { UAParser } from 'ua-parser-js'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; @@ -20,7 +17,7 @@ import { DataTablePagination } from '@documenso/ui/primitives/data-table-paginat import { Skeleton } from '@documenso/ui/primitives/skeleton'; import { TableCell } from '@documenso/ui/primitives/table'; -export type DocumentLogsDataTableProps = { +export type DocumentLogsTableProps = { documentId: number; }; @@ -29,10 +26,10 @@ const dateFormat: DateTimeFormatOptions = { hourCycle: 'h12', }; -export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => { +export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => { const { _, i18n } = useLingui(); - const searchParams = useSearchParams(); + const [searchParams] = useSearchParams(); const updateSearchParams = useUpdateSearchParams(); const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/remix/app/components/tables/documents-table-action-button.tsx similarity index 80% rename from apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx rename to apps/remix/app/components/tables/documents-table-action-button.tsx index 1194dfd01..97230c359 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/remix/app/components/tables/documents-table-action-button.tsx @@ -1,46 +1,44 @@ -'use client'; - -import Link from 'next/link'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { Document, Recipient, Team, User } from '@prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react'; -import { useSession } from 'next-auth/react'; +import { Link } from 'react-router'; import { match } from 'ts-pattern'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; -import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; -import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { Button } from '@documenso/ui/primitives/button'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type DataTableActionButtonProps = { +import { useOptionalCurrentTeam } from '~/providers/team'; + +export type DocumentsTableActionButtonProps = { row: Document & { user: Pick; recipients: Recipient[]; team: Pick | null; }; - team?: Pick; }; -export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => { - const { data: session } = useSession(); +export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => { + const { user } = useSession(); const { toast } = useToast(); const { _ } = useLingui(); - if (!session) { - return null; - } + const team = useOptionalCurrentTeam(); - const recipient = row.recipients.find((recipient) => recipient.email === session.user.email); + const recipient = row.recipients.find((recipient) => recipient.email === user.email); - const isOwner = row.user.id === session.user.id; + const isOwner = row.user.id === user.id; const isRecipient = !!recipient; const isDraft = row.status === DocumentStatus.DRAFT; const isPending = row.status === DocumentStatus.PENDING; - const isComplete = row.status === DocumentStatus.COMPLETED; + const isComplete = isDocumentCompleted(row.status); const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const role = recipient?.role; const isCurrentTeamDocument = team && row.team?.url === team.url; @@ -98,7 +96,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true }, () => ( @@ -106,7 +106,7 @@ export const TeamBillingInvoicesDataTable = ({ teamId }: TeamBillingInvoicesData asChild disabled={typeof row.original.hostedInvoicePdf !== 'string'} > - + Download diff --git a/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx b/apps/remix/app/components/tables/team-settings-member-invites-table.tsx similarity index 93% rename from apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx rename to apps/remix/app/components/tables/team-settings-member-invites-table.tsx index 8a57be81c..fbe1346df 100644 --- a/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx +++ b/apps/remix/app/components/tables/team-settings-member-invites-table.tsx @@ -1,12 +1,10 @@ -'use client'; - import { useMemo } from 'react'; -import { useSearchParams } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { History, MoreHorizontal, Trash2 } from 'lucide-react'; +import { useSearchParams } from 'react-router'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; @@ -27,13 +25,12 @@ import { Skeleton } from '@documenso/ui/primitives/skeleton'; import { TableCell } from '@documenso/ui/primitives/table'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export type TeamMemberInvitesDataTableProps = { - teamId: number; -}; +import { useCurrentTeam } from '~/providers/team'; -export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTableProps) => { - const searchParams = useSearchParams(); +export const TeamSettingsMemberInvitesTable = () => { + const [searchParams] = useSearchParams(); const updateSearchParams = useUpdateSearchParams(); + const team = useCurrentTeam(); const { _, i18n } = useLingui(); const { toast } = useToast(); @@ -42,7 +39,7 @@ export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTabl const { data, isLoading, isLoadingError } = trpc.team.findTeamMemberInvites.useQuery( { - teamId, + teamId: team.id, query: parsedSearchParams.query, page: parsedSearchParams.page, perPage: parsedSearchParams.perPage, @@ -142,7 +139,7 @@ export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTabl resendTeamMemberInvitation({ - teamId, + teamId: team.id, invitationId: row.original.id, }) } @@ -154,7 +151,7 @@ export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTabl deleteTeamMemberInvitations({ - teamId, + teamId: team.id, invitationIds: [row.original.id], }) } diff --git a/apps/web/src/components/(teams)/tables/team-members-data-table.tsx b/apps/remix/app/components/tables/team-settings-members-table.tsx similarity index 82% rename from apps/web/src/components/(teams)/tables/team-members-data-table.tsx rename to apps/remix/app/components/tables/team-settings-members-table.tsx index e92efb727..d1d658ecd 100644 --- a/apps/web/src/components/(teams)/tables/team-members-data-table.tsx +++ b/apps/remix/app/components/tables/team-settings-members-table.tsx @@ -1,19 +1,16 @@ -'use client'; - import { useMemo } from 'react'; -import { useSearchParams } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { Edit, MoreHorizontal, Trash2 } from 'lucide-react'; +import { useSearchParams } from 'react-router'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; -import type { TeamMemberRole } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; @@ -29,32 +26,22 @@ import { import { Skeleton } from '@documenso/ui/primitives/skeleton'; import { TableCell } from '@documenso/ui/primitives/table'; -import { DeleteTeamMemberDialog } from '../dialogs/delete-team-member-dialog'; -import { UpdateTeamMemberDialog } from '../dialogs/update-team-member-dialog'; +import { TeamMemberDeleteDialog } from '~/components/dialogs/team-member-delete-dialog'; +import { TeamMemberUpdateDialog } from '~/components/dialogs/team-member-update-dialog'; +import { useCurrentTeam } from '~/providers/team'; -export type TeamMembersDataTableProps = { - currentUserTeamRole: TeamMemberRole; - teamOwnerUserId: number; - teamId: number; - teamName: string; -}; - -export const TeamMembersDataTable = ({ - currentUserTeamRole, - teamOwnerUserId, - teamId, - teamName, -}: TeamMembersDataTableProps) => { +export const TeamSettingsMembersDataTable = () => { const { _, i18n } = useLingui(); - const searchParams = useSearchParams(); + const [searchParams] = useSearchParams(); const updateSearchParams = useUpdateSearchParams(); + const team = useCurrentTeam(); const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); const { data, isLoading, isLoadingError } = trpc.team.findTeamMembers.useQuery( { - teamId, + teamId: team.id, query: parsedSearchParams.query, page: parsedSearchParams.page, perPage: parsedSearchParams.perPage, @@ -103,7 +90,7 @@ export const TeamMembersDataTable = ({ header: _(msg`Role`), accessorKey: 'role', cell: ({ row }) => - teamOwnerUserId === row.original.userId + team.ownerUserId === row.original.userId ? _(msg`Owner`) : _(TEAM_MEMBER_ROLE_MAP[row.original.role]), }, @@ -125,8 +112,8 @@ export const TeamMembersDataTable = ({ Actions - e.preventDefault()} title="Update team member role" @@ -146,9 +133,9 @@ export const TeamMembersDataTable = ({ } /> - e.preventDefault()} disabled={ - teamOwnerUserId === row.original.userId || - !isTeamRoleWithinUserHierarchy(currentUserTeamRole, row.original.role) + team.ownerUserId === row.original.userId || + !isTeamRoleWithinUserHierarchy(team.currentTeamMember.role, row.original.role) } title={_(msg`Remove team member`)} > diff --git a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx b/apps/remix/app/components/tables/templates-table-action-dropdown.tsx similarity index 61% rename from apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx rename to apps/remix/app/components/tables/templates-table-action-dropdown.tsx index 28fac6118..a1c12e34b 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx +++ b/apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -1,14 +1,11 @@ -'use client'; - import { useState } from 'react'; -import Link from 'next/link'; +import { Trans } from '@lingui/react/macro'; +import type { Recipient, Template, TemplateDirectLink } from '@prisma/client'; +import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2, Upload } from 'lucide-react'; +import { Link } from 'react-router'; -import { Trans } from '@lingui/macro'; -import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react'; -import { useSession } from 'next-auth/react'; - -import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client'; +import { useSession } from '@documenso/lib/client-only/providers/session'; import { DropdownMenu, DropdownMenuContent, @@ -17,37 +14,44 @@ import { DropdownMenuTrigger, } from '@documenso/ui/primitives/dropdown-menu'; -import { DeleteTemplateDialog } from './delete-template-dialog'; -import { DuplicateTemplateDialog } from './duplicate-template-dialog'; -import { MoveTemplateDialog } from './move-template-dialog'; -import { TemplateDirectLinkDialog } from './template-direct-link-dialog'; +import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog'; +import { TemplateDeleteDialog } from '../dialogs/template-delete-dialog'; +import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog'; +import { TemplateDuplicateDialog } from '../dialogs/template-duplicate-dialog'; +import { TemplateMoveDialog } from '../dialogs/template-move-dialog'; -export type DataTableActionDropdownProps = { +export type TemplatesTableActionDropdownProps = { row: Template & { directLink?: Pick | null; recipients: Recipient[]; }; templateRootPath: string; teamId?: number; + onDelete?: () => Promise | void; + onMove?: ({ + templateId, + teamUrl, + }: { + templateId: number; + teamUrl: string; + }) => Promise | void; }; -export const DataTableActionDropdown = ({ +export const TemplatesTableActionDropdown = ({ row, templateRootPath, teamId, -}: DataTableActionDropdownProps) => { - const { data: session } = useSession(); + onDelete, + onMove, +}: TemplatesTableActionDropdownProps) => { + const { user } = useSession(); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false); const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); const [isMoveDialogOpen, setMoveDialogOpen] = useState(false); - if (!session) { - return null; - } - - const isOwner = row.userId === session.user.id; + const isOwner = row.userId === user.id; const isTeamTemplate = row.teamId === teamId; return ( @@ -60,7 +64,7 @@ export const DataTableActionDropdown = ({ Action - + Edit @@ -79,13 +83,24 @@ export const DataTableActionDropdown = ({ Direct link - {!teamId && ( + {!teamId && !row.teamId && ( setMoveDialogOpen(true)}> Move to Team )} + + + Bulk Send via CSV +
  • + } + /> + setDeleteDialogOpen(true)} @@ -95,9 +110,8 @@ export const DataTableActionDropdown = ({ - @@ -108,17 +122,18 @@ export const DataTableActionDropdown = ({ onOpenChange={setTemplateDirectLinkDialogOpen} /> - - ); diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/remix/app/components/tables/templates-table.tsx similarity index 65% rename from apps/web/src/app/(dashboard)/templates/data-table-templates.tsx rename to apps/remix/app/components/tables/templates-table.tsx index d198cdab5..08f46fe40 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx +++ b/apps/remix/app/components/tables/templates-table.tsx @@ -1,54 +1,61 @@ -'use client'; - import { useMemo, useTransition } from 'react'; -import Link from 'next/link'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from 'lucide-react'; +import { Link } from 'react-router'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import type { FindTemplateRow } from '@documenso/trpc/server/template-router/schema'; +import { formatTemplatesPath } from '@documenso/lib/utils/teams'; +import type { TFindTemplatesResponse } from '@documenso/trpc/server/template-router/schema'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; -import { TemplateType } from '~/components/formatter/template-type'; +import { TemplateType } from '~/components/general/template/template-type'; +import { useOptionalCurrentTeam } from '~/providers/team'; -import { DataTableActionDropdown } from './data-table-action-dropdown'; -import { DataTableTitle } from './data-table-title'; -import { TemplateDirectLinkBadge } from './template-direct-link-badge'; -import { UseTemplateDialog } from './use-template-dialog'; +import { TemplateUseDialog } from '../dialogs/template-use-dialog'; +import { TemplateDirectLinkBadge } from '../general/template/template-direct-link-badge'; +import { TemplatesTableActionDropdown } from './templates-table-action-dropdown'; -type TemplatesDataTableProps = { - templates: FindTemplateRow[]; - perPage: number; - page: number; - totalPages: number; +type TemplatesTableProps = { + data?: TFindTemplatesResponse; + isLoading?: boolean; + isLoadingError?: boolean; documentRootPath: string; templateRootPath: string; - teamId?: number; }; -export const TemplatesDataTable = ({ - templates, - perPage, - page, - totalPages, +type TemplatesTableRow = TFindTemplatesResponse['data'][number]; + +export const TemplatesTable = ({ + data, + isLoading, + isLoadingError, documentRootPath, templateRootPath, - teamId, -}: TemplatesDataTableProps) => { +}: TemplatesTableProps) => { + const { _, i18n } = useLingui(); + const { remaining } = useLimits(); + + const team = useOptionalCurrentTeam(); + const [isPending, startTransition] = useTransition(); const updateSearchParams = useUpdateSearchParams(); - const { _, i18n } = useLingui(); - const { remaining } = useLimits(); + const formatTemplateLink = (row: TemplatesTableRow) => { + const isCurrentTeamTemplate = team?.url && row.team?.url === team?.url; + const path = formatTemplatesPath(isCurrentTeamTemplate ? team?.url : undefined); + return `${path}/${row.id}`; + }; const columns = useMemo(() => { return [ @@ -59,7 +66,14 @@ export const TemplatesDataTable = ({ }, { header: _(msg`Title`), - cell: ({ row }) => , + cell: ({ row }) => ( + + {row.original.title} + + ), }, { header: () => ( @@ -102,11 +116,11 @@ export const TemplatesDataTable = ({
  • - {teamId ? Team Only : Private} + {team?.id ? Team Only : Private}

    - {teamId ? ( + {team?.id ? ( Team only templates are not linked anywhere and are visible only to your team. @@ -142,7 +156,7 @@ export const TemplatesDataTable = ({ cell: ({ row }) => { return (

    - -
    ); }, }, - ] satisfies DataTableColumnDef<(typeof templates)[number]>[]; - }, [documentRootPath, teamId, templateRootPath]); + ] satisfies DataTableColumnDef[]; + }, [documentRootPath, team?.id, templateRootPath]); const onPaginationChange = (page: number, perPage: number) => { startTransition(() => { @@ -171,6 +185,13 @@ export const TemplatesDataTable = ({ }); }; + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + return (
    {remaining.documents === 0 && ( @@ -182,7 +203,7 @@ export const TemplatesDataTable = ({ You have reached your document limit.{' '} - + Upgrade your account to continue! @@ -192,11 +213,39 @@ export const TemplatesDataTable = ({ + + + + + + + +
    + +
    +
    + + + + + + + + ), + }} > {(table) => }
    diff --git a/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx b/apps/remix/app/components/tables/user-settings-current-teams-table.tsx similarity index 86% rename from apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx rename to apps/remix/app/components/tables/user-settings-current-teams-table.tsx index d9984aace..688a5cb62 100644 --- a/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx +++ b/apps/remix/app/components/tables/user-settings-current-teams-table.tsx @@ -1,17 +1,16 @@ -'use client'; - import { useMemo } from 'react'; -import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { useSearchParams } from 'react-router'; +import { Link } from 'react-router'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import { NEXT_PUBLIC_WEBAPP_URL, WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; @@ -22,12 +21,12 @@ import { DataTablePagination } from '@documenso/ui/primitives/data-table-paginat import { Skeleton } from '@documenso/ui/primitives/skeleton'; import { TableCell } from '@documenso/ui/primitives/table'; -import { LeaveTeamDialog } from '../dialogs/leave-team-dialog'; +import { TeamLeaveDialog } from '~/components/dialogs/team-leave-dialog'; -export const CurrentUserTeamsDataTable = () => { +export const UserSettingsCurrentTeamsDataTable = () => { const { _, i18n } = useLingui(); - const searchParams = useSearchParams(); + const [searchParams] = useSearchParams(); const updateSearchParams = useUpdateSearchParams(); const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); @@ -63,15 +62,15 @@ export const CurrentUserTeamsDataTable = () => { header: _(msg`Team`), accessorKey: 'name', cell: ({ row }) => ( - + {row.original.name} } - secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`} + secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/t/${row.original.url}`} /> ), @@ -95,13 +94,13 @@ export const CurrentUserTeamsDataTable = () => {
    {canExecuteTeamAction('MANAGE_TEAM', row.original.currentTeamMember.role) && ( )} - void; }; -export const PendingUserTeamsDataTableActions = ({ +export const UserSettingsPendingTeamsTableActions = ({ className, pendingTeamId, onPayClick, -}: PendingUserTeamsDataTableActionsProps) => { +}: UserSettingsPendingTeamsTableActionsProps) => { const { _ } = useLingui(); const { toast } = useToast(); diff --git a/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx b/apps/remix/app/components/tables/user-settings-pending-teams-table.tsx similarity index 87% rename from apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx rename to apps/remix/app/components/tables/user-settings-pending-teams-table.tsx index b656308f7..bdd86b4eb 100644 --- a/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx +++ b/apps/remix/app/components/tables/user-settings-pending-teams-table.tsx @@ -1,14 +1,11 @@ -'use client'; - import { useEffect, useMemo, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; - -import { msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { useSearchParams } from 'react-router'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; import { trpc } from '@documenso/trpc/react'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; @@ -18,13 +15,14 @@ import { DataTablePagination } from '@documenso/ui/primitives/data-table-paginat import { Skeleton } from '@documenso/ui/primitives/skeleton'; import { TableCell } from '@documenso/ui/primitives/table'; -import { CreateTeamCheckoutDialog } from '../dialogs/create-team-checkout-dialog'; -import { PendingUserTeamsDataTableActions } from './pending-user-teams-data-table-actions'; +import { TeamCheckoutCreateDialog } from '~/components/dialogs/team-checkout-create-dialog'; -export const PendingUserTeamsDataTable = () => { +import { UserSettingsPendingTeamsTableActions } from './user-settings-pending-teams-table-actions'; + +export const UserSettingsPendingTeamsDataTable = () => { const { _, i18n } = useLingui(); - const searchParams = useSearchParams(); + const [searchParams] = useSearchParams(); const updateSearchParams = useUpdateSearchParams(); const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); @@ -68,7 +66,7 @@ export const PendingUserTeamsDataTable = () => { primaryText={ {row.original.name} } - secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`} + secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/t/${row.original.url}`} /> ), }, @@ -80,7 +78,7 @@ export const PendingUserTeamsDataTable = () => { { id: 'actions', cell: ({ row }) => ( - { {(table) => } - setCheckoutPendingTeamId(null)} /> diff --git a/apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx b/apps/remix/app/components/tables/user-settings-teams-page-table.tsx similarity index 71% rename from apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx rename to apps/remix/app/components/tables/user-settings-teams-page-table.tsx index bac1dbf44..8773c9b73 100644 --- a/apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx +++ b/apps/remix/app/components/tables/user-settings-teams-page-table.tsx @@ -1,27 +1,24 @@ -'use client'; - import { useEffect, useState } from 'react'; -import Link from 'next/link'; -import { usePathname, useRouter, useSearchParams } from 'next/navigation'; - -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { Link, useSearchParams } from 'react-router'; +import { useLocation } from 'react-router'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { trpc } from '@documenso/trpc/react'; import { Input } from '@documenso/ui/primitives/input'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; -import { CurrentUserTeamsDataTable } from './current-user-teams-data-table'; -import { PendingUserTeamsDataTable } from './pending-user-teams-data-table'; +import { UserSettingsCurrentTeamsDataTable } from './user-settings-current-teams-table'; +import { UserSettingsPendingTeamsDataTable } from './user-settings-pending-teams-table'; export const UserSettingsTeamsPageDataTable = () => { const { _ } = useLingui(); - const searchParams = useSearchParams(); - const router = useRouter(); - const pathname = usePathname(); + const [searchParams, setSearchParams] = useSearchParams(); + const { pathname } = useLocation(); const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? ''); @@ -40,10 +37,6 @@ export const UserSettingsTeamsPageDataTable = () => { * Handle debouncing the search query. */ useEffect(() => { - if (!pathname) { - return; - } - const params = new URLSearchParams(searchParams?.toString()); params.set('query', debouncedSearchQuery); @@ -52,8 +45,8 @@ export const UserSettingsTeamsPageDataTable = () => { params.delete('query'); } - router.push(`${pathname}?${params.toString()}`); - }, [debouncedSearchQuery, pathname, router, searchParams]); + setSearchParams(params); + }, [debouncedSearchQuery, pathname, searchParams]); return (
    @@ -67,13 +60,13 @@ export const UserSettingsTeamsPageDataTable = () => { - + Active - + Pending {data && data.count > 0 && ( {data.count} @@ -84,7 +77,11 @@ export const UserSettingsTeamsPageDataTable = () => {
    - {currentTab === 'pending' ? : } + {currentTab === 'pending' ? ( + + ) : ( + + )}
    ); }; diff --git a/apps/remix/app/entry.client.tsx b/apps/remix/app/entry.client.tsx new file mode 100644 index 000000000..4b5769ff5 --- /dev/null +++ b/apps/remix/app/entry.client.tsx @@ -0,0 +1,48 @@ +import { StrictMode, startTransition, useEffect } from 'react'; + +import { i18n } from '@lingui/core'; +import { detect, fromHtmlTag } from '@lingui/detect-locale'; +import { I18nProvider } from '@lingui/react'; +import posthog from 'posthog-js'; +import { hydrateRoot } from 'react-dom/client'; +import { HydratedRouter } from 'react-router/dom'; + +import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags'; +import { dynamicActivate } from '@documenso/lib/utils/i18n'; + +function PosthogInit() { + const postHogConfig = extractPostHogConfig(); + + useEffect(() => { + if (postHogConfig) { + posthog.init(postHogConfig.key, { + api_host: postHogConfig.host, + capture_exceptions: true, + }); + } + }, []); + + return null; +} + +async function main() { + const locale = detect(fromHtmlTag('lang')) || 'en'; + + await dynamicActivate(locale); + + startTransition(() => { + hydrateRoot( + document, + + + + + + + , + ); + }); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +main(); diff --git a/apps/remix/app/entry.server.tsx b/apps/remix/app/entry.server.tsx new file mode 100644 index 000000000..6caeba746 --- /dev/null +++ b/apps/remix/app/entry.server.tsx @@ -0,0 +1,82 @@ +import { i18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { createReadableStreamFromReadable } from '@react-router/node'; +import { isbot } from 'isbot'; +import { PassThrough } from 'node:stream'; +import type { RenderToPipeableStreamOptions } from 'react-dom/server'; +import { renderToPipeableStream } from 'react-dom/server'; +import type { AppLoadContext, EntryContext } from 'react-router'; +import { ServerRouter } from 'react-router'; + +import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; +import { dynamicActivate, extractLocaleData } from '@documenso/lib/utils/i18n'; + +import { langCookie } from './storage/lang-cookie.server'; + +export const streamTimeout = 5_000; + +export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + _loadContext: AppLoadContext, +) { + let language = await langCookie.parse(request.headers.get('cookie') ?? ''); + + if (!APP_I18N_OPTIONS.supportedLangs.includes(language)) { + language = extractLocaleData({ headers: request.headers }).lang; + } + + await dynamicActivate(language); + + return new Promise((resolve, reject) => { + let shellRendered = false; + const userAgent = request.headers.get('user-agent'); + + // Ensure requests from bots and SPA Mode renders wait for all content to load before responding + // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation + const readyOption: keyof RenderToPipeableStreamOptions = + (userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady'; + + const { pipe, abort } = renderToPipeableStream( + + + , + { + [readyOption]() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + // Abort the rendering stream after the `streamTimeout` so it has time to + // flush down the rejected boundaries + setTimeout(abort, streamTimeout + 1000); + }); +} diff --git a/apps/web/src/providers/team.tsx b/apps/remix/app/providers/team.tsx similarity index 72% rename from apps/web/src/providers/team.tsx rename to apps/remix/app/providers/team.tsx index 88455d475..f0777d6d0 100644 --- a/apps/web/src/providers/team.tsx +++ b/apps/remix/app/providers/team.tsx @@ -1,16 +1,16 @@ -'use client'; - import { createContext, useContext } from 'react'; import React from 'react'; -import type { TGetTeamByIdResponse } from '@documenso/lib/server-only/team/get-team'; +import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; + +type TeamProviderValue = TGetTeamsResponse[0]; interface TeamProviderProps { children: React.ReactNode; - team: TGetTeamByIdResponse; + team: TeamProviderValue; } -const TeamContext = createContext(null); +const TeamContext = createContext(null); export const useCurrentTeam = () => { const context = useContext(TeamContext); diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx new file mode 100644 index 000000000..c9a453622 --- /dev/null +++ b/apps/remix/app/root.tsx @@ -0,0 +1,183 @@ +import { useEffect } from 'react'; + +import Plausible from 'plausible-tracker'; +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, + data, + isRouteErrorResponse, + useLoaderData, + useLocation, +} from 'react-router'; +import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes'; + +import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { SessionProvider } from '@documenso/lib/client-only/providers/session'; +import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n'; +import { type TGetTeamsResponse, getTeams } from '@documenso/lib/server-only/team/get-teams'; +import { createPublicEnv, env } from '@documenso/lib/utils/env'; +import { extractLocaleData } from '@documenso/lib/utils/i18n'; +import { TrpcProvider } from '@documenso/trpc/react'; +import { Toaster } from '@documenso/ui/primitives/toaster'; +import { TooltipProvider } from '@documenso/ui/primitives/tooltip'; + +import type { Route } from './+types/root'; +import stylesheet from './app.css?url'; +import { GenericErrorLayout } from './components/general/generic-error-layout'; +import { langCookie } from './storage/lang-cookie.server'; +import { themeSessionResolver } from './storage/theme-session.server'; +import { appMetaTags } from './utils/meta'; + +const { trackPageview } = Plausible({ + domain: 'documenso.com', + trackLocalhost: false, +}); + +export const links: Route.LinksFunction = () => [ + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: 'anonymous', + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Caveat:wght@400..600&display=swap', + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap', + }, + { rel: 'stylesheet', href: stylesheet }, +]; + +export function meta() { + return appMetaTags(); +} + +/** + * Don't revalidate (run the loader on sequential navigations) on the root layout + * + * Update values via providers. + */ +export const shouldRevalidate = () => false; + +export async function loader({ request }: Route.LoaderArgs) { + const session = await getOptionalSession(request); + + let teams: TGetTeamsResponse = []; + + if (session.isAuthenticated) { + teams = await getTeams({ userId: session.user.id }); + } + + const { getTheme } = await themeSessionResolver(request); + + let lang: SupportedLanguageCodes = await langCookie.parse(request.headers.get('cookie') ?? ''); + + if (!APP_I18N_OPTIONS.supportedLangs.includes(lang)) { + lang = extractLocaleData({ headers: request.headers }).lang; + } + + return data( + { + lang, + theme: getTheme(), + session: session.isAuthenticated + ? { + user: session.user, + session: session.session, + teams, + } + : null, + publicEnv: createPublicEnv(), + }, + { + headers: { + 'Set-Cookie': await langCookie.serialize(lang), + }, + }, + ); +} + +export function Layout({ children }: { children: React.ReactNode }) { + const { theme } = useLoaderData() || {}; + + const location = useLocation(); + + useEffect(() => { + if (env('NODE_ENV') === 'production') { + trackPageview(); + } + }, [location.pathname]); + + return ( + + {children} + + ); +} + +export function LayoutContent({ children }: { children: React.ReactNode }) { + const { publicEnv, session, lang, ...data } = useLoaderData() || {}; + + const [theme] = useTheme(); + + return ( + + + + + + + + + + + + + + + {/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */} + + + + + + + {children} + + + + + + + + + +