diff --git a/.env.example b/.env.example index 87ad09a63..7b8872b69 100644 --- a/.env.example +++ b/.env.example @@ -136,3 +136,5 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123" # OPTIONAL: The file to save the logger output to. Will disable stdout if provided. NEXT_PRIVATE_LOGGER_FILE_PATH= +# [[PLAIN SUPPORT]] +NEXT_PRIVATE_PLAIN_API_KEY= diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..b6fba867d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# Agent Guidelines for Documenso + +## Build/Test/Lint Commands + +- `npm run build` - Build all packages +- `npm run lint` - Lint all packages +- `npm run lint:fix` - Auto-fix linting issues +- `npm run test:e2e` - Run E2E tests with Playwright +- `npm run test:dev -w @documenso/app-tests` - Run single E2E test in dev mode +- `npm run test-ui:dev -w @documenso/app-tests` - Run E2E tests with UI +- `npm run format` - Format code with Prettier +- `npm run dev` - Start development server for Remix app + +## Code Style Guidelines + +- Use TypeScript for all code; prefer `type` over `interface` +- Use functional components with `const Component = () => {}` +- Never use classes; prefer functional/declarative patterns +- Use descriptive variable names with auxiliary verbs (isLoading, hasError) +- Directory names: lowercase with dashes (auth-wizard) +- Use named exports for components +- Never use 'use client' directive +- Never use 1-line if statements +- Structure files: exported component, subcomponents, helpers, static content, types + +## Error Handling & Validation + +- Use custom AppError class when throwing errors +- When catching errors on the frontend use `const error = AppError.parse(error)` to get the error code +- Use early returns and guard clauses +- Use Zod for form validation and react-hook-form for forms +- Use error boundaries for unexpected errors + +## UI & Styling + +- Use Shadcn UI, Radix, and Tailwind CSS with mobile-first approach +- Use `
` `` elements with fieldset having `:disabled` attribute when loading +- Use Lucide icons with longhand names (HomeIcon vs Home) + +## TRPC Routes + +- Each route in own file: `routers/teams/create-team.ts` +- Associated types file: `routers/teams/create-team.types.ts` +- Request/response schemas: `Z[RouteName]RequestSchema`, `Z[RouteName]ResponseSchema` +- Only use GET and POST methods in OpenAPI meta +- Deconstruct input argument on its own line +- Prefer route names such as get/getMany/find/create/update/delete +- "create" routes request schema should have the ID and data in the top level +- "update" routes request schema should have the ID in the top level and the data in a nested "data" object + +## Translations & Remix + +- Use `string` for JSX translations from `@lingui/react/macro` +- Use `t\`string\`` macro for TypeScript translations +- Use `(params: Route.Params)` and `(loaderData: Route.LoaderData)` for routes +- Directly return data from loaders, don't use `json()` +- Use `superLoaderJson` when sending complex data through loaders such as dates or prisma decimals diff --git a/README.md b/README.md index f44b88c2a..185d9839e 100644 --- a/README.md +++ b/README.md @@ -214,8 +214,6 @@ For detailed instructions on how to configure and run the Docker container, plea We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates! -> Please note that the below deployment methods are for v0.9, we will update these to v1.0 once it has been released. - ### Fetch, configure, and build First, clone the code from Github: @@ -258,7 +256,7 @@ npm run start This will start the server on `localhost:3000`. For now, any reverse proxy can then do the frontend and SSL termination. -> If you want to run with another port than 3000, you can start the application with `next -p ` from the `apps/web` folder. +> If you want to run with another port than 3000, you can start the application with `next -p ` from the `apps/remix` folder. ### Run as a service @@ -308,7 +306,7 @@ The Web UI can be found at http://localhost:9000, while the SMTP port will be on ### Support IPv6 -If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Next.js start command +If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Remix start command For local docker run diff --git a/SIGNING.md b/SIGNING.md index d8f664cee..3eb94fbfb 100644 --- a/SIGNING.md +++ b/SIGNING.md @@ -18,7 +18,7 @@ For the digital signature of your documents you need a signing certificate in .p 4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**) -5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created) +5. Place the certificate `/apps/remix/resources/certificate.p12` (If the path does not exist, it needs to be created) ## Docker diff --git a/apps/documentation/pages/developers/contributing/contributing-translations.mdx b/apps/documentation/pages/developers/contributing/contributing-translations.mdx index e313a4dc1..b8ffbb362 100644 --- a/apps/documentation/pages/developers/contributing/contributing-translations.mdx +++ b/apps/documentation/pages/developers/contributing/contributing-translations.mdx @@ -25,7 +25,7 @@ The translation files are organized into folders represented by their respective Each PO file contains translations which look like this: ```po -#: apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx:61 +#: apps/remix/app/(signing)/sign/[token]/no-longer-available.tsx:61 msgid "Want to send slick signing links like this one? <0>Check out Documenso." msgstr "Möchten Sie auffällige Signatur-Links wie diesen senden? <0>Überprüfen Sie Documenso." ``` diff --git a/apps/documentation/pages/developers/self-hosting/how-to.mdx b/apps/documentation/pages/developers/self-hosting/how-to.mdx index 4025ce6d0..08797e801 100644 --- a/apps/documentation/pages/developers/self-hosting/how-to.mdx +++ b/apps/documentation/pages/developers/self-hosting/how-to.mdx @@ -54,7 +54,7 @@ Install the project dependencies as follows: ```bash npm i -npm run build:web +npm run build npm run prisma:migrate-deploy ``` @@ -69,7 +69,7 @@ npm run start This will start the server on `localhost:3000`. Any reverse proxy can handle the front end and SSL termination. - If you want to run with another port than `3000`, you can start the application with `next -p ` from the `apps/web` folder. + If you want to run with another port than `3000`, you can start the application with `next -p ` from the `apps/remix` folder. @@ -249,7 +249,7 @@ After=network.target Environment=PATH=/path/to/your/node/binaries Type=simple User=www-data -WorkingDirectory=/var/www/documenso/apps/web +WorkingDirectory=/var/www/documenso/apps/remix ExecStart=/usr/bin/next start -p 3500 TimeoutSec=15 Restart=always diff --git a/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx b/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx index 26692fedb..9f82d8551 100644 --- a/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx @@ -34,7 +34,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo const [reason, setReason] = useState(''); const { mutateAsync: deleteDocument, isPending: isDeletingDocument } = - trpc.admin.deleteDocument.useMutation(); + trpc.admin.document.delete.useMutation(); const handleDeleteDocument = async () => { try { diff --git a/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx index 58ffa9c85..157cc5284 100644 --- a/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx @@ -3,12 +3,12 @@ import { useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { User } from '@prisma/client'; import { useNavigate } from 'react-router'; import { match } from 'ts-pattern'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; +import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -25,7 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type AdminUserDeleteDialogProps = { className?: string; - user: User; + user: TGetUserResponse; }; export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialogProps) => { @@ -35,7 +35,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog const [email, setEmail] = useState(''); const { mutateAsync: deleteUser, isPending: isDeletingUser } = - trpc.admin.deleteUser.useMutation(); + trpc.admin.user.delete.useMutation(); const onDeleteAccount = async () => { try { diff --git a/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx index ee42931a9..347532a19 100644 --- a/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx @@ -3,11 +3,11 @@ import { useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { User } from '@prisma/client'; import { match } from 'ts-pattern'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; +import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type AdminUserDisableDialogProps = { className?: string; - userToDisable: User; + userToDisable: TGetUserResponse; }; export const AdminUserDisableDialog = ({ @@ -37,7 +37,7 @@ export const AdminUserDisableDialog = ({ const [email, setEmail] = useState(''); const { mutateAsync: disableUser, isPending: isDisablingUser } = - trpc.admin.disableUser.useMutation(); + trpc.admin.user.disable.useMutation(); const onDisableAccount = async () => { try { diff --git a/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx index 1718c9e97..64f9aa72d 100644 --- a/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -3,11 +3,11 @@ import { useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { User } from '@prisma/client'; import { match } from 'ts-pattern'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; +import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type AdminUserEnableDialogProps = { className?: string; - userToEnable: User; + userToEnable: TGetUserResponse; }; export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnableDialogProps) => { @@ -34,7 +34,7 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab const [email, setEmail] = useState(''); const { mutateAsync: enableUser, isPending: isEnablingUser } = - trpc.admin.enableUser.useMutation(); + trpc.admin.user.enable.useMutation(); const onEnableAccount = async () => { try { diff --git a/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx new file mode 100644 index 000000000..59372ecc9 --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { useRevalidator } from 'react-router'; +import { match } from 'ts-pattern'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminUserResetTwoFactorDialogProps = { + className?: string; + user: TGetUserResponse; +}; + +export const AdminUserResetTwoFactorDialog = ({ + className, + user, +}: AdminUserResetTwoFactorDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + const { revalidate } = useRevalidator(); + const [email, setEmail] = useState(''); + const [open, setOpen] = useState(false); + + const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } = + trpc.admin.user.resetTwoFactor.useMutation(); + + const onResetTwoFactor = async () => { + try { + await resetTwoFactor({ + userId: user.id, + }); + + toast({ + title: _(msg`2FA Reset`), + description: _(msg`The user's two factor authentication has been reset successfully.`), + duration: 5000, + }); + + await revalidate(); + setOpen(false); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code) + .with(AppErrorCode.NOT_FOUND, () => msg`User not found.`) + .with( + AppErrorCode.UNAUTHORIZED, + () => msg`You are not authorized to reset two factor authentcation for this user.`, + ) + .otherwise( + () => msg`An error occurred while resetting two factor authentication for the user.`, + ); + + toast({ + title: _(msg`Error`), + description: _(errorMessage), + variant: 'destructive', + duration: 7500, + }); + } + }; + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + + if (!newOpen) { + setEmail(''); + } + }; + + return ( +
+ +
+ Reset Two Factor Authentication + + + Reset the users two factor authentication. This action is irreversible and will + disable two factor authentication for the user. + + +
+ +
+ + + + + + + + + Reset Two Factor Authentication + + + + + + + This action is irreversible. Please ensure you have informed the user before + proceeding. + + + + +
+ + + To confirm, please enter the accounts email address
({user.email}). +
+
+ + setEmail(e.target.value)} + /> +
+ + + + +
+
+
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/document-delete-dialog.tsx b/apps/remix/app/components/dialogs/document-delete-dialog.tsx index 746ef1570..a802387ef 100644 --- a/apps/remix/app/components/dialogs/document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-delete-dialog.tsx @@ -49,7 +49,7 @@ export const DocumentDeleteDialog = ({ const [inputValue, setInputValue] = useState(''); const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT); - const { mutateAsync: deleteDocument, isPending } = trpcReact.document.deleteDocument.useMutation({ + const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({ onSuccess: async () => { void refreshLimits(); diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index bb87f99dc..57146ed9f 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -36,11 +36,12 @@ export const DocumentDuplicateDialog = ({ const team = useCurrentTeam(); - const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery( + const { data: document, isLoading } = trpcReact.document.get.useQuery( { documentId: id, }, { + queryHash: `document-duplicate-dialog-${id}`, enabled: open === true, }, ); @@ -55,7 +56,7 @@ export const DocumentDuplicateDialog = ({ const documentsPath = formatDocumentsPath(team.url); const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } = - trpcReact.document.duplicateDocument.useMutation({ + trpcReact.document.duplicate.useMutation({ onSuccess: async ({ documentId }) => { toast({ title: _(msg`Document Duplicated`), diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx index b3dc69503..e1a97ecc1 100644 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -71,7 +71,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia document.status !== 'PENDING' || !recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED); - const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation(); + const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation(); const form = useForm({ resolver: zodResolver(ZResendDocumentFormSchema), diff --git a/apps/remix/app/components/dialogs/passkey-create-dialog.tsx b/apps/remix/app/components/dialogs/passkey-create-dialog.tsx index 6a21895d3..8bfcbe3e5 100644 --- a/apps/remix/app/components/dialogs/passkey-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/passkey-create-dialog.tsx @@ -65,9 +65,9 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre }); const { mutateAsync: createPasskeyRegistrationOptions, isPending } = - trpc.auth.createPasskeyRegistrationOptions.useMutation(); + trpc.auth.passkey.createRegistrationOptions.useMutation(); - const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation(); + const { mutateAsync: createPasskey } = trpc.auth.passkey.create.useMutation(); const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => { setFormError(null); diff --git a/apps/remix/app/components/dialogs/token-delete-dialog.tsx b/apps/remix/app/components/dialogs/token-delete-dialog.tsx index aa557132b..bd20c2377 100644 --- a/apps/remix/app/components/dialogs/token-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/token-delete-dialog.tsx @@ -56,7 +56,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe type TDeleteTokenByIdMutationSchema = z.infer; - const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({ + const { mutateAsync: deleteTokenMutation } = trpc.apiToken.delete.useMutation({ onSuccess() { onDelete?.(); }, diff --git a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx index 60b732a76..21faee750 100644 --- a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx +++ b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -172,6 +172,8 @@ export const ConfigureFieldsView = ({ name: 'fields', }); + const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber)); + const onFieldCopy = useCallback( (event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => { const { duplicate = false, duplicateAll = false } = options ?? {}; @@ -540,7 +542,9 @@ export const ConfigureFieldsView = ({
- + {localFields.map((field, index) => { const recipientIndex = recipients.findIndex( (r) => r.id === field.recipientId, diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index 09f6a91d2..8b883a36b 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -91,6 +91,8 @@ export const EmbedDirectTemplateClientPage = ({ localFields.filter((field) => field.inserted), ]; + const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page)); + const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE); const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } = @@ -442,7 +444,9 @@ export const EmbedDirectTemplateClientPage = ({
- + {showPendingFieldTooltip && pendingFields.length > 0 && ( Click to insert field diff --git a/apps/remix/app/components/embed/embed-document-fields.tsx b/apps/remix/app/components/embed/embed-document-fields.tsx index 561fdf4cb..ea14b3f1f 100644 --- a/apps/remix/app/components/embed/embed-document-fields.tsx +++ b/apps/remix/app/components/embed/embed-document-fields.tsx @@ -50,8 +50,10 @@ export const EmbedDocumentFields = ({ onSignField, onUnsignField, }: EmbedDocumentFieldsProps) => { + const highestPageNumber = Math.max(...fields.map((field) => field.page)); + return ( - + {fields.map((field) => match(field.type) .with(FieldType.SIGNATURE, () => ( diff --git a/apps/remix/app/components/embed/embed-document-signing-page.tsx b/apps/remix/app/components/embed/embed-document-signing-page.tsx index ef2eedc1c..d7d8a0713 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page.tsx @@ -106,6 +106,8 @@ export const EmbedSignDocumentClientPage = ({ fields.filter((field) => field.inserted), ]; + const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page)); + const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } = trpc.recipient.completeDocumentWithToken.useMutation(); @@ -465,7 +467,9 @@ export const EmbedSignDocumentClientPage = ({ - + {showPendingFieldTooltip && pendingFields.length > 0 && ( Click to insert field diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx index 37abdcce3..5867a1a2a 100644 --- a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -92,6 +92,8 @@ export const MultiSignDocumentSigningView = ({ [], ]; + const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page)); + const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? []; const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => { @@ -357,7 +359,9 @@ export const MultiSignDocumentSigningView = ({ {hasDocumentLoaded && ( - + {showPendingFieldTooltip && pendingFields.length > 0 && ( ({ values: { diff --git a/apps/remix/app/components/forms/support-ticket-form.tsx b/apps/remix/app/components/forms/support-ticket-form.tsx new file mode 100644 index 000000000..e80f12d21 --- /dev/null +++ b/apps/remix/app/components/forms/support-ticket-form.tsx @@ -0,0 +1,138 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +const ZSupportTicketSchema = z.object({ + subject: z.string().min(3, 'Subject is required'), + message: z.string().min(10, 'Message must be at least 10 characters'), +}); + +type TSupportTicket = z.infer; + +export type SupportTicketFormProps = { + organisationId: string; + teamId?: string | null; + onSuccess?: () => void; + onClose?: () => void; +}; + +export const SupportTicketForm = ({ + organisationId, + teamId, + onSuccess, + onClose, +}: SupportTicketFormProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const { mutateAsync: submitSupportTicket, isPending } = + trpc.profile.submitSupportTicket.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZSupportTicketSchema), + defaultValues: { + subject: '', + message: '', + }, + }); + + const isLoading = form.formState.isLoading || isPending; + + const onSubmit = async (data: TSupportTicket) => { + const { subject, message } = data; + + try { + await submitSupportTicket({ + subject, + message, + organisationId, + teamId, + }); + + toast({ + title: t`Support ticket created`, + description: t`Your support request has been submitted. We'll get back to you soon!`, + }); + + if (onSuccess) { + onSuccess(); + } + + form.reset(); + } catch (err) { + toast({ + title: t`Failed to create support ticket`, + description: t`An error occurred. Please try again later.`, + variant: 'destructive', + }); + } + }; + + return ( + <> + + +
+ ( + + + Subject + + + + + + + )} + /> + + ( + + + Message + + +