From 948d1bbf124b04502e29dc530ec9cee6a63e3944 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 22 Jun 2026 12:16:55 +1000 Subject: [PATCH 01/17] fix: improve team member removal ux (#3001) --- .../dialogs/team-member-delete-dialog.tsx | 31 +++++- .../components/tables/team-members-table.tsx | 56 ++++++---- .../app-tests/e2e/teams/team-members.spec.ts | 105 ++++++++++++++++++ 3 files changed, 167 insertions(+), 25 deletions(-) create mode 100644 packages/app-tests/e2e/teams/team-members.spec.ts diff --git a/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx index 841268dfc..1dda81788 100644 --- a/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx @@ -16,6 +16,17 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { useState } from 'react'; +import { match } from 'ts-pattern'; + +/** + * The reason a team member cannot be removed from the team. When set, the delete + * dialog explains the reason instead of offering a confirm button. + */ +export type TeamMemberDeleteDisableReason = + | 'TEAM_OWNER' + | 'HIGHER_ROLE' + | 'INHERIT_MEMBER_ENABLED' + | 'INHERITED_MEMBER'; export type TeamMemberDeleteDialogProps = { teamId: number; @@ -23,7 +34,7 @@ export type TeamMemberDeleteDialogProps = { memberId: string; memberName: string; memberEmail: string; - isInheritMemberEnabled: boolean | null; + disableReason?: TeamMemberDeleteDisableReason | null; trigger?: React.ReactNode; }; @@ -34,7 +45,7 @@ export const TeamMemberDeleteDialog = ({ memberId, memberName, memberEmail, - isInheritMemberEnabled, + disableReason, }: TeamMemberDeleteDialogProps) => { const [open, setOpen] = useState(false); @@ -86,10 +97,19 @@ export const TeamMemberDeleteDialog = ({ - {isInheritMemberEnabled ? ( + {disableReason ? ( - You cannot remove members from this team if the inherit member feature is enabled. + {match(disableReason) + .with('TEAM_OWNER', () => You cannot remove the organisation owner from the team.) + .with('HIGHER_ROLE', () => You cannot remove a member with a role higher than your own.) + .with('INHERIT_MEMBER_ENABLED', () => ( + You cannot remove members from this team while the inherit member feature is enabled. + )) + .with('INHERITED_MEMBER', () => ( + This member is inherited from a group and cannot be removed from the team directly. + )) + .exhaustive()} ) : ( @@ -109,11 +129,10 @@ export const TeamMemberDeleteDialog = ({ Close - {!isInheritMemberEnabled && ( + {!disableReason && ( - - {filteredFolders?.map((folder) => ( - - ))} - - {searchTerm && filteredFolders?.length === 0 && ( -
- No folders found -
- )} - - )} - - - - - )} - /> - - - - - - - - - - - ); -}; diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx deleted file mode 100644 index 493d07138..000000000 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { useSession } from '@documenso/lib/client-only/providers/session'; -import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; -import { AppError } from '@documenso/lib/errors/app-error'; -import type { TRecipientLite } from '@documenso/lib/types/recipient'; -import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import type { Document } from '@documenso/prisma/types/document-legacy-schema'; -import { trpc as trpcReact } from '@documenso/trpc/react'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { Checkbox } from '@documenso/ui/primitives/checkbox'; -import { - Dialog, - DialogClose, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@documenso/ui/primitives/dialog'; -import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu'; -import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form'; -import { useToast } from '@documenso/ui/primitives/use-toast'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { SigningStatus, type Team, type User } from '@prisma/client'; -import { History } from 'lucide-react'; -import { useState } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; -import * as z from 'zod'; - -import { useCurrentTeam } from '~/providers/team'; -import { getDistributeErrorMessage } from '~/utils/toast-error-messages'; - -import { StackAvatar } from '../general/stack-avatar'; - -const FORM_ID = 'resend-email'; - -export type DocumentResendDialogProps = { - document: Pick & { - user: Pick; - recipients: TRecipientLite[]; - team: Pick | null; - }; - recipients: TRecipientLite[]; -}; - -export const ZResendDocumentFormSchema = z.object({ - recipients: z.array(z.number()).min(1, { - message: 'You must select at least one item.', - }), -}); - -export type TResendDocumentFormSchema = z.infer; - -export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => { - const { user } = useSession(); - const team = useCurrentTeam(); - - const { toast } = useToast(); - const { _ } = useLingui(); - - const [isOpen, setIsOpen] = useState(false); - const isOwner = document.userId === user.id; - const isCurrentTeamDocument = team && document.team?.url === team.url; - - const isDisabled = - (!isOwner && !isCurrentTeamDocument) || - document.status !== 'PENDING' || - !recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED); - - const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation(); - - const form = useForm({ - resolver: zodResolver(ZResendDocumentFormSchema), - defaultValues: { - recipients: [], - }, - }); - - const { - handleSubmit, - formState: { isSubmitting }, - } = form; - - const selectedRecipients = useWatch({ - control: form.control, - name: 'recipients', - }); - - const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => { - try { - await resendDocument({ documentId: document.id, recipients }); - - toast({ - title: _(msg`Document re-sent`), - description: _(msg`Your document has been re-sent successfully.`), - duration: 5000, - }); - - setIsOpen(false); - } catch (err) { - const error = AppError.parseError(err); - const errorMessage = getDistributeErrorMessage(error.code); - - toast({ - title: _(errorMessage.title), - description: _(errorMessage.description), - variant: 'destructive', - duration: 7500, - }); - } - }; - - return ( - - - e.preventDefault()}> - - Resend - - - - - - -

- Who do you want to remind? -

-
-
- -
- - ( - <> - {recipients.map((recipient) => ( - - - - {recipient.email} - - - - - checked - ? onChange([...value, recipient.id]) - : onChange(value.filter((v) => v !== recipient.id)) - } - /> - - - ))} - - )} - /> - - - - -
- - - - - -
-
-
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx index 161fd94d0..1a54c7893 100644 --- a/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx @@ -25,14 +25,16 @@ import { Trans, useLingui } from '@lingui/react/macro'; import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; import * as z from 'zod'; import { getDistributeErrorMessage } from '~/utils/toast-error-messages'; import { StackAvatar } from '../general/stack-avatar'; export type EnvelopeRedistributeDialogProps = { - envelope: Pick & { + envelope: Pick & { recipients: TEnvelopeRecipientLite[]; }; + envelopeType?: EnvelopeType; trigger?: React.ReactNode; }; @@ -44,7 +46,7 @@ export const ZEnvelopeRedistributeFormSchema = z.object({ export type TEnvelopeRedistributeFormSchema = z.infer; -export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedistributeDialogProps) => { +export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }: EnvelopeRedistributeDialogProps) => { const recipients = envelope.recipients; const { toast } = useToast(); @@ -70,9 +72,23 @@ export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedist try { await redistributeEnvelope({ envelopeId: envelope.id, recipients }); + const successMessage = match(envelopeType) + .with(EnvelopeType.DOCUMENT, () => ({ + title: t`Document resent`, + description: t`Your document has been resent successfully.`, + })) + .with(EnvelopeType.TEMPLATE, () => ({ + title: t`Template resent`, + description: t`Your template has been resent successfully.`, + })) + .otherwise(() => ({ + title: t`Envelope resent`, + description: t`Your envelope has been resent successfully.`, + })); + toast({ - title: t`Envelope resent`, - description: t`Your envelope has been resent successfully.`, + title: successMessage.title, + description: successMessage.description, duration: 5000, }); diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx index f0c81da77..69dbb8793 100644 --- a/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx @@ -28,7 +28,7 @@ export type EnvelopesBulkMoveDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; currentFolderId?: string; - onSuccess?: () => void; + onSuccess?: (folderId: string | null) => Promise | void; } & Omit; const ZBulkMoveFormSchema = z.object({ @@ -99,11 +99,12 @@ export const EnvelopesBulkMoveDialog = ({ await trpcUtils.template.findTemplates.invalidate(); } + await onSuccess?.(data.folderId); + toast({ description: t`Selected items have been moved.`, }); - onSuccess?.(); onOpenChange(false); } catch (err) { const error = AppError.parseError(err); diff --git a/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx deleted file mode 100644 index df619f149..000000000 --- a/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { FolderType } from '@documenso/lib/types/folder-type'; -import { formatTemplatesPath } from '@documenso/lib/utils/teams'; -import { trpc } from '@documenso/trpc/react'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; -import { useToast } from '@documenso/ui/primitives/use-toast'; -import { zodResolver } from '@hookform/resolvers/zod'; -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 { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { useNavigate } from 'react-router'; -import { z } from 'zod'; - -import { useCurrentTeam } from '~/providers/team'; - -export type TemplateMoveToFolderDialogProps = { - templateId: number; - templateTitle: string; - isOpen: boolean; - onOpenChange: (open: boolean) => void; - currentFolderId?: string | null; -} & Omit; - -const ZMoveTemplateFormSchema = z.object({ - folderId: z.string().nullable().optional(), -}); - -type TMoveTemplateFormSchema = z.infer; - -export function TemplateMoveToFolderDialog({ - templateId, - templateTitle, - isOpen, - onOpenChange, - currentFolderId, - ...props -}: TemplateMoveToFolderDialogProps) { - const { _ } = useLingui(); - const { toast } = useToast(); - - const navigate = useNavigate(); - const team = useCurrentTeam(); - - const [searchTerm, setSearchTerm] = useState(''); - - const form = useForm({ - resolver: zodResolver(ZMoveTemplateFormSchema), - defaultValues: { - folderId: currentFolderId ?? null, - }, - }); - - const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery( - { - parentId: currentFolderId ?? null, - type: FolderType.TEMPLATE, - }, - { - enabled: isOpen, - }, - ); - - const { mutateAsync: updateTemplate } = trpc.template.updateTemplate.useMutation(); - - useEffect(() => { - if (!isOpen) { - form.reset(); - setSearchTerm(''); - } else { - form.reset({ folderId: currentFolderId ?? null }); - } - }, [isOpen, currentFolderId, form]); - - const onSubmit = async (data: TMoveTemplateFormSchema) => { - try { - await updateTemplate({ - templateId, - data: { - folderId: data.folderId ?? null, - }, - }); - - toast({ - title: _(msg`Template moved`), - description: _(msg`The template has been moved successfully.`), - variant: 'default', - }); - - onOpenChange(false); - - const templatesPath = formatTemplatesPath(team.url); - - if (data.folderId) { - void navigate(`${templatesPath}/f/${data.folderId}`); - } else { - void navigate(templatesPath); - } - } catch (err) { - const error = AppError.parseError(err); - - if (error.code === AppErrorCode.NOT_FOUND) { - toast({ - title: _(msg`Error`), - description: _(msg`The folder you are trying to move the template to does not exist.`), - variant: 'destructive', - }); - - return; - } - - toast({ - title: _(msg`Error`), - description: _(msg`An error occurred while moving the template.`), - variant: 'destructive', - }); - } - }; - - const filteredFolders = folders?.data?.filter((folder) => - folder.name.toLowerCase().includes(searchTerm.toLowerCase()), - ); - - return ( - - - - - Move Template to Folder - - - - Move "{templateTitle}" to a folder - - - -
- - setSearchTerm(e.target.value)} - className="pl-8" - /> -
- -
- - ( - - - Folder - - - -
- {isFoldersLoading ? ( -
- -
- ) : ( - <> - - - {filteredFolders?.map((folder) => ( - - ))} - - {searchTerm && filteredFolders?.length === 0 && ( -
- No folders found -
- )} - - )} -
-
- -
- )} - /> - - - - - - - - -
-
- ); -} diff --git a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx index e18852d95..668a0cd2e 100644 --- a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx +++ b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx @@ -19,6 +19,7 @@ import { Download, Edit, FileOutputIcon, + History, Loader, MoreHorizontal, Pencil, @@ -29,10 +30,10 @@ import { import { useState } from 'react'; import { Link, useNavigate } from 'react-router'; -import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog'; import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog'; +import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog'; import { EnvelopeRenameDialog } from '~/components/dialogs/envelope-rename-dialog'; import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog'; import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog'; @@ -67,8 +68,6 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP const documentsPath = formatDocumentsPath(team.url); - const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED'); - return ( @@ -172,13 +171,20 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP /> )} - + {canManageDocument && ( + e.preventDefault()}> +
+ + Resend +
+ + } + /> + )} item.signingStatus !== 'SIGNED'); - return ( @@ -244,7 +243,25 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT /> )} - + {canManageDocument && ( + e.preventDefault()}> +
+ + Resend +
+ + } + /> + )} void; + onMoveDocument?: (envelopeId: string) => void; enableSelection?: boolean; rowSelection?: RowSelectionState; onRowSelectionChange?: (selection: RowSelectionState) => void; @@ -117,7 +117,7 @@ export const DocumentsTable = ({ onMoveDocument(row.original.id) : undefined} + onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.envelopeId) : undefined} /> ), diff --git a/apps/remix/app/components/tables/templates-table-action-dropdown.tsx b/apps/remix/app/components/tables/templates-table-action-dropdown.tsx index 8872a1a78..9d8a288f5 100644 --- a/apps/remix/app/components/tables/templates-table-action-dropdown.tsx +++ b/apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -11,15 +11,15 @@ import { Trans } from '@lingui/react/macro'; import { DocumentStatus, EnvelopeType, type TemplateDirectLink } from '@prisma/client'; import { Copy, Download, Edit, FolderIcon, MoreHorizontal, Pencil, Share2Icon, Trash2, Upload } from 'lucide-react'; import { useState } from 'react'; -import { Link } from 'react-router'; +import { Link, useNavigate } from 'react-router'; import { EnvelopeDeleteDialog } from '../dialogs/envelope-delete-dialog'; import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog'; import { EnvelopeDuplicateDialog } from '../dialogs/envelope-duplicate-dialog'; import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog'; +import { EnvelopesBulkMoveDialog } from '../dialogs/envelopes-bulk-move-dialog'; import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog'; import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog'; -import { TemplateMoveToFolderDialog } from '../dialogs/template-move-to-folder-dialog'; export type TemplatesTableActionDropdownProps = { row: { @@ -44,6 +44,7 @@ export const TemplatesTableActionDropdown = ({ onDelete, }: TemplatesTableActionDropdownProps) => { const trpcUtils = trpcReact.useUtils(); + const navigate = useNavigate(); const [isRenameDialogOpen, setRenameDialogOpen] = useState(false); const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false); @@ -153,12 +154,13 @@ export const TemplatesTableActionDropdown = ({ )} - navigate(folderId ? `${templateRootPath}/f/${folderId}` : templateRootPath)} /> (null); + const [documentToMove, setDocumentToMove] = useState(null); const [rowSelection, setRowSelection] = useSessionStorage('documents-bulk-selection', {}); const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false); @@ -200,8 +202,8 @@ export default function DocumentsPage() { data={data} isLoading={isLoading} isLoadingError={isLoadingError} - onMoveDocument={(documentId) => { - setDocumentToMove(documentId); + onMoveDocument={(envelopeId) => { + setDocumentToMove(envelopeId); setIsMovingDocument(true); }} enableSelection @@ -213,8 +215,9 @@ export default function DocumentsPage() { {documentToMove && ( - { @@ -224,6 +227,9 @@ export default function DocumentsPage() { setDocumentToMove(null); } }} + onSuccess={(destinationFolderId) => + navigate(destinationFolderId ? `${documentsPath}/f/${destinationFolderId}` : documentsPath) + } /> )} diff --git a/packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts b/packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts index 5aaaa3a58..7af9a2310 100644 --- a/packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts +++ b/packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts @@ -270,7 +270,7 @@ test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) => await page.getByLabel('test.documenso.com').first().click(); await page.getByRole('button', { name: 'Send reminder' }).click(); - await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({ + await expect(page.getByText('Document resent', { exact: true })).toBeVisible({ timeout: 10_000, }); diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts index 3e8192b78..6d5f2ca08 100644 --- a/packages/app-tests/e2e/teams/team-documents.spec.ts +++ b/packages/app-tests/e2e/teams/team-documents.spec.ts @@ -238,7 +238,7 @@ test('[TEAMS]: resend pending team document', async ({ page }) => { await page.getByLabel('test.documenso.com').first().click(); await page.getByRole('button', { name: 'Send reminder' }).click(); - await expectToastTextToBeVisible(page, 'Document re-sent'); + await expectToastTextToBeVisible(page, 'Document resent'); }); test('[TEAMS]: delete draft team document', async ({ page }) => { From 0bf58ca66e842f9c7e93c78b04664c15180e8f77 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 22 Jun 2026 14:33:34 +1000 Subject: [PATCH 06/17] feat: add custom brand colours to emails (#3005) --- packages/email/package.json | 5 +- packages/email/preview/.gitignore | 2 + packages/email/preview/app/app.css | 9 + .../preview/app/components/playground.tsx | 337 +++++++++++++++ .../preview/app/components/prop-fields.tsx | 113 +++++ packages/email/preview/app/entry.client.tsx | 12 + packages/email/preview/app/entry.server.tsx | 56 +++ packages/email/preview/app/lib/templates.tsx | 407 ++++++++++++++++++ packages/email/preview/app/lib/viewports.ts | 10 + packages/email/preview/app/root.tsx | 30 ++ packages/email/preview/app/routes.ts | 7 + packages/email/preview/app/routes/$slug.tsx | 35 ++ packages/email/preview/app/routes/_index.tsx | 13 + .../email/preview/app/routes/api.render.tsx | 61 +++ packages/email/preview/postcss.config.cjs | 6 + packages/email/preview/react-router.config.ts | 6 + packages/email/preview/tailwind.config.cjs | 24 ++ packages/email/preview/tsconfig.json | 30 ++ packages/email/preview/vite.config.ts | 72 ++++ packages/email/providers/branding.tsx | 2 + packages/email/render.tsx | 57 ++- .../template-access-auth-2fa.tsx | 14 +- .../template-admin-user-created.tsx | 14 +- .../template-confirmation-email.tsx | 8 +- .../template-custom-message-body.tsx | 2 +- .../template-document-cancel.tsx | 6 +- .../template-document-completed.tsx | 8 +- .../template-document-invite.tsx | 6 +- .../template-document-pending.tsx | 6 +- .../template-document-recipient-signed.tsx | 6 +- .../template-document-rejected.tsx | 6 +- .../template-document-rejection-confirmed.tsx | 4 +- .../template-document-reminder.tsx | 8 +- .../template-document-self-signed.tsx | 16 +- .../template-document-super-delete.tsx | 10 +- .../template-components/template-footer.tsx | 14 +- .../template-forgot-password.tsx | 6 +- .../template-recipient-expired.tsx | 6 +- .../template-reset-password.tsx | 6 +- packages/email/templates/access-auth-2fa.tsx | 4 +- .../email/templates/admin-user-created.tsx | 4 +- .../email/templates/bulk-send-complete.tsx | 8 +- packages/email/templates/confirm-email.tsx | 4 +- .../email/templates/confirm-team-email.tsx | 14 +- packages/email/templates/document-cancel.tsx | 4 +- .../email/templates/document-completed.tsx | 4 +- .../document-created-from-direct-template.tsx | 10 +- packages/email/templates/document-invite.tsx | 8 +- packages/email/templates/document-pending.tsx | 4 +- .../templates/document-recipient-signed.tsx | 4 +- .../email/templates/document-rejected.tsx | 4 +- .../document-rejection-confirmed.tsx | 4 +- .../email/templates/document-reminder.tsx | 6 +- .../email/templates/document-self-signed.tsx | 4 +- .../email/templates/document-super-delete.tsx | 4 +- packages/email/templates/forgot-password.tsx | 4 +- ...organisation-account-link-confirmation.tsx | 12 +- .../email/templates/organisation-delete.tsx | 10 +- .../email/templates/organisation-invite.tsx | 16 +- .../email/templates/organisation-join.tsx | 10 +- .../email/templates/organisation-leave.tsx | 10 +- .../templates/organisation-limit-alert.tsx | 10 +- .../email/templates/recipient-expired.tsx | 4 +- .../recipient-removed-from-document.tsx | 6 +- packages/email/templates/reset-password.tsx | 12 +- packages/email/templates/team-delete.tsx | 10 +- .../email/templates/team-email-removed.tsx | 10 +- packages/email/tsconfig.json | 2 +- .../server-only/email/get-email-context.ts | 30 +- packages/lib/utils/email-branding-colors.ts | 105 +++++ .../utils/team-global-settings-to-branding.ts | 10 + 71 files changed, 1599 insertions(+), 192 deletions(-) create mode 100644 packages/email/preview/.gitignore create mode 100644 packages/email/preview/app/app.css create mode 100644 packages/email/preview/app/components/playground.tsx create mode 100644 packages/email/preview/app/components/prop-fields.tsx create mode 100644 packages/email/preview/app/entry.client.tsx create mode 100644 packages/email/preview/app/entry.server.tsx create mode 100644 packages/email/preview/app/lib/templates.tsx create mode 100644 packages/email/preview/app/lib/viewports.ts create mode 100644 packages/email/preview/app/root.tsx create mode 100644 packages/email/preview/app/routes.ts create mode 100644 packages/email/preview/app/routes/$slug.tsx create mode 100644 packages/email/preview/app/routes/_index.tsx create mode 100644 packages/email/preview/app/routes/api.render.tsx create mode 100644 packages/email/preview/postcss.config.cjs create mode 100644 packages/email/preview/react-router.config.ts create mode 100644 packages/email/preview/tailwind.config.cjs create mode 100644 packages/email/preview/tsconfig.json create mode 100644 packages/email/preview/vite.config.ts create mode 100644 packages/lib/utils/email-branding-colors.ts diff --git a/packages/email/package.json b/packages/email/package.json index f7791ef5b..bb549f70b 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -12,12 +12,13 @@ "index.ts" ], "scripts": { - "dev": "email dev --port 3002 --dir templates", + "dev": "react-router dev --config preview/vite.config.ts", + "preview:build": "react-router build --config preview/vite.config.ts", "clean": "rimraf node_modules" }, "dependencies": { - "@documenso/tailwind-config": "*", "@documenso/nodemailer-resend": "4.0.0", + "@documenso/tailwind-config": "*", "@react-email/body": "0.2.0", "@react-email/button": "0.2.0", "@react-email/code-block": "0.2.0", diff --git a/packages/email/preview/.gitignore b/packages/email/preview/.gitignore new file mode 100644 index 000000000..d41162243 --- /dev/null +++ b/packages/email/preview/.gitignore @@ -0,0 +1,2 @@ +/.react-router/ +/build/ diff --git a/packages/email/preview/app/app.css b/packages/email/preview/app/app.css new file mode 100644 index 000000000..32dbdf8f8 --- /dev/null +++ b/packages/email/preview/app/app.css @@ -0,0 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body { + margin: 0; + padding: 0; +} diff --git a/packages/email/preview/app/components/playground.tsx b/packages/email/preview/app/components/playground.tsx new file mode 100644 index 000000000..6cdbd53e2 --- /dev/null +++ b/packages/email/preview/app/components/playground.tsx @@ -0,0 +1,337 @@ +import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/locales'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router'; + +import type { FieldConfig } from '../lib/templates'; +import { templates } from '../lib/templates'; +import { viewports } from '../lib/viewports'; +import { PropFields } from './prop-fields'; + +type Theme = 'light' | 'dark'; + +const GROUP_ORDER = ['Documents', 'Recipients', 'Organisations', 'Teams', 'Account', 'Admin'] as const; + +const LANGUAGE_LABELS: Record = { + en: 'English', + de: 'German', + fr: 'French', + es: 'Spanish', + it: 'Italian', + nl: 'Dutch', + pl: 'Polish', + 'pt-BR': 'Portuguese (Brazil)', + ja: 'Japanese', + ko: 'Korean', + zh: 'Chinese', +}; + +const DEFAULT_COLORS = { + primary: '#a2e771', + primaryForeground: '#162c07', + background: '#ffffff', + foreground: '#0f172a', +}; + +type PlaygroundProps = { + slug: string; + fields: Record; + defaultProps: Record; +}; + +export const EmailPlayground = ({ slug, fields, defaultProps }: PlaygroundProps) => { + const navigate = useNavigate(); + + const [props, setProps] = useState(defaultProps); + const [html, setHtml] = useState(''); + const [loading, setLoading] = useState(false); + + const [theme, setTheme] = useState('light'); + const [viewportIndex, setViewportIndex] = useState(2); + const [lang, setLang] = useState('en'); + + const [brandingEnabled, setBrandingEnabled] = useState(false); + const [colors, setColors] = useState(DEFAULT_COLORS); + + const debounceRef = useRef>(undefined); + + const groupedTemplates = useMemo(() => { + const entries = Object.entries(templates); + + return GROUP_ORDER.map((group) => ({ + group, + entries: entries.filter(([, def]) => def.group === group), + })).filter((section) => section.entries.length > 0); + }, []); + + const fetchHtml = useCallback( + async (currentProps: Record, currentLang: string, brandColors: typeof colors | null) => { + setLoading(true); + + try { + const response = await fetch('/api/render', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slug, + props: currentProps, + lang: currentLang, + colors: brandColors, + assetBaseUrl: window.location.origin, + }), + }); + + if (response.ok) { + setHtml(await response.text()); + } + } finally { + setLoading(false); + } + }, + [slug], + ); + + // Reset props when navigating to a different template. + useEffect(() => { + setProps(defaultProps); + }, [defaultProps]); + + // Re-render on any input change (debounced). + useEffect(() => { + clearTimeout(debounceRef.current); + + debounceRef.current = setTimeout(() => { + void fetchHtml(props, lang, brandingEnabled ? colors : null); + }, 250); + + return () => clearTimeout(debounceRef.current); + }, [props, lang, brandingEnabled, colors, fetchHtml]); + + const handlePropChange = (key: string, value: unknown) => { + setProps((prev) => ({ ...prev, [key]: value })); + }; + + const handleColorChange = (key: keyof typeof colors, value: string) => { + setColors((prev) => ({ ...prev, [key]: value })); + }; + + // Force dark mode inside the iframe by neutralising the prefers-color-scheme + // media query (color-scheme alone doesn't trigger it inside an iframe). + const displayHtml = theme === 'dark' && html ? html.replaceAll(/prefers-color-scheme:\s*dark/g, 'min-width:0') : html; + + const viewport = viewports[viewportIndex]; + + return ( +
+ {/* Sidebar */} + + + {/* Props panel */} +
+

Props

+ +
+ + {/* Main */} +
+ + +
+
+