chore: merged main

This commit is contained in:
Catalin Pit
2026-06-09 08:32:31 +03:00
130 changed files with 7382 additions and 1508 deletions
@@ -54,7 +54,6 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
if (plan[interval] && plan[interval].isVisibleInApp) {
prices.push({
...plan[interval],
memberCount: plan.memberCount,
claim: plan.id,
});
}
@@ -120,12 +119,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
<Trans>Subscribe</Trans>
</IndividualPersonalLayoutCheckoutButton>
) : (
<BillingDialog
priceId={price.id}
planName={price.product.name}
memberCount={price.memberCount}
claim={price.claim}
/>
<BillingDialog priceId={price.id} planName={price.product.name} claim={price.claim} />
)}
</CardContent>
</MotionCard>
@@ -136,16 +130,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
);
};
const BillingDialog = ({
priceId,
planName,
claim,
}: {
priceId: string;
planName: string;
memberCount: number;
claim: string;
}) => {
const BillingDialog = ({ priceId, planName, claim }: { priceId: string; planName: string; claim: string }) => {
const [isOpen, setIsOpen] = useState(false);
const { t } = useLingui();
@@ -1,4 +1,5 @@
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TTemplate } from '@documenso/lib/types/template';
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -17,6 +18,7 @@ import { useNavigate, useSearchParams } from 'react-router';
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages';
import { DirectTemplateConfigureForm, type TDirectTemplateConfigureFormSchema } from './direct-template-configure-form';
import { type DirectTemplateLocalField, DirectTemplateSigningForm } from './direct-template-signing-form';
@@ -120,9 +122,12 @@ export const DirectTemplatePageView = ({
await navigate(`/sign/${token}/complete`);
}
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = getDirectTemplateErrorMessage(error.code);
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
});
@@ -1,6 +1,7 @@
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -25,9 +26,9 @@ import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useCurrentTeam } from '~/providers/team';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
export type DocumentEditFormProps = {
className?: string;
@@ -387,9 +388,12 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
} catch (err) {
console.error(err);
const error = AppError.parseError(err);
const errorMessage = getDistributeErrorMessage(error.code);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while sending the document.`),
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
});
}
@@ -3,7 +3,7 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { AppError } from '@documenso/lib/errors/app-error';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
@@ -20,9 +20,9 @@ import { EnvelopeType } from '@prisma/client';
import { useMemo, useState } from 'react';
import type { FileRejection } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentTeam } from '~/providers/team';
import { getUploadErrorMessage } from '~/utils/toast-error-messages';
export type DocumentUploadButtonLegacyProps = {
className?: string;
@@ -130,30 +130,11 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
console.error(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs.`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => msg`You have reached the limit of the number of files per envelope.`,
)
.with('UNSUPPORTED_FILE_TYPE', () => msg`This file type isn't supported. Please upload a PDF or Word document.`)
.with(
'CONVERSION_SERVICE_UNAVAILABLE',
() => msg`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
)
.with(
'CONVERSION_FAILED',
() => msg`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
)
.otherwise(() => msg`An error occurred while uploading your document.`);
const errorMessage = getUploadErrorMessage(error.code);
toast({
title: _(msg`Error`),
description: _(errorMessage),
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
@@ -14,13 +14,22 @@ import {
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { CommandDialog } from '@documenso/ui/primitives/command';
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@documenso/ui/primitives/command';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Transformer } from 'konva/lib/shapes/Transformer';
import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
import { CopyPlusIcon, ShapesIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
@@ -470,6 +479,22 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
}
};
const changeSelectedFieldsType = (type: FieldType) => {
const fields = selectedKonvaFieldGroups
.map((field) => editorFields.getFieldByFormId(field.id()))
.filter((field) => field !== undefined);
for (const field of fields) {
if (field.type !== type) {
editorFields.updateFieldByFormId(field.formId, {
type,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
id: undefined,
});
}
}
};
const duplicatedSelectedFields = () => {
const fields = selectedKonvaFieldGroups
.map((field) => editorFields.getFieldByFormId(field.id()))
@@ -554,6 +579,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
handleChangeFieldType={changeSelectedFieldsType}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
style={{
position: 'absolute',
@@ -602,6 +628,7 @@ type FieldActionButtonsProps = React.HTMLAttributes<HTMLDivElement> & {
handleDuplicateSelectedFieldsOnAllPages: () => void;
handleDeleteSelectedFields: () => void;
handleChangeRecipient: (recipientId: number) => void;
handleChangeFieldType: (type: FieldType) => void;
selectedFieldFormId: string[];
};
@@ -610,15 +637,40 @@ const FieldActionButtons = ({
handleDuplicateSelectedFieldsOnAllPages,
handleDeleteSelectedFields,
handleChangeRecipient,
handleChangeFieldType,
selectedFieldFormId,
...props
}: FieldActionButtonsProps) => {
const { t } = useLingui();
const [showRecipientSelector, setShowRecipientSelector] = useState(false);
const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false);
const { editorFields, envelope } = useCurrentEnvelopeEditor();
/**
* Decide the preselected field type in the command input.
*
* If all fields share the same type, use that as the default selection.
* Otherwise show no preselection.
*/
const preselectedFieldType = useMemo(() => {
if (selectedFieldFormId.length === 0) {
return null;
}
const fields = editorFields.localFields.filter((field) => selectedFieldFormId.includes(field.formId));
if (fields.length === 0) {
return null;
}
const firstType = fields[0].type;
const isTypesSame = fields.every((field) => field.type === firstType);
return isTypesSame ? firstType : null;
}, [editorFields.localFields, selectedFieldFormId]);
/**
* Decide the preselected recipient in the command input.
*
@@ -656,6 +708,7 @@ const FieldActionButtons = ({
<div className="flex flex-col items-center" {...props}>
<div className="group flex w-fit items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
<button
type="button"
title={t`Change Recipient`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={() => setShowRecipientSelector(true)}
@@ -665,6 +718,17 @@ const FieldActionButtons = ({
</button>
<button
type="button"
title={t`Change Field Type`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={() => setShowFieldTypeSelector(true)}
onTouchEnd={() => setShowFieldTypeSelector(true)}
>
<ShapesIcon className="h-3 w-3" />
</button>
<button
type="button"
title={t`Duplicate`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={handleDuplicateSelectedFields}
@@ -674,6 +738,7 @@ const FieldActionButtons = ({
</button>
<button
type="button"
title={t`Duplicate on all pages`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={handleDuplicateSelectedFieldsOnAllPages}
@@ -683,6 +748,7 @@ const FieldActionButtons = ({
</button>
<button
type="button"
title={t`Remove`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={handleDeleteSelectedFields}
@@ -705,6 +771,41 @@ const FieldActionButtons = ({
fields={envelope.fields}
/>
</CommandDialog>
<CommandDialog position="start" open={showFieldTypeSelector} onOpenChange={setShowFieldTypeSelector}>
<Command defaultValue={preselectedFieldType ? t(FRIENDLY_FIELD_TYPE[preselectedFieldType]) : undefined}>
<CommandInput placeholder={t`Select a field type`} />
<CommandList>
<CommandEmpty>
<span className="inline-block px-4 text-muted-foreground">
{t`No field type matching this description was found.`}
</span>
</CommandEmpty>
<CommandGroup>
{fieldButtonList.map((field) => {
const FieldIcon = field.icon;
const label = t(FRIENDLY_FIELD_TYPE[field.type]);
return (
<CommandItem
key={field.type}
className="px-2"
onSelect={() => {
handleChangeFieldType(field.type);
setShowFieldTypeSelector(false);
}}
>
<FieldIcon className="mr-2 h-4 w-4" />
<span className="truncate">{label}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
</div>
);
};
@@ -21,6 +21,7 @@ import {
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -586,6 +587,9 @@ export const EnvelopeEditorRecipientForm = () => {
}
}, [formValues]);
const recipientCountLimit = organisation.organisationClaim.recipientCount;
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
return (
<Card backdropBlur={false} className="border">
<CardHeader className="flex flex-row justify-between">
@@ -650,6 +654,17 @@ export const EnvelopeEditorRecipientForm = () => {
</CardHeader>
<CardContent>
{isOverRecipientLimit && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>
<Trans>
This envelope cannot have more than {recipientCountLimit} recipients. Please contact support if you need
more.
</Trans>
</AlertDescription>
</Alert>
)}
<Form {...form}>
<div
className={cn('-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4', {
@@ -9,7 +9,7 @@ import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZDocumentAccessAuthTypesSchema, ZDocumentActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { DocumentEmailEvents, ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import {
type TDocumentMetaDateFormat,
ZDocumentMetaDateFormatSchema,
@@ -39,6 +39,7 @@ import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expira
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
import { TemplateTypeSelect, TemplateTypeTooltip } from '@documenso/ui/components/template/template-type-select';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Combobox } from '@documenso/ui/primitives/combobox';
@@ -114,7 +115,7 @@ export const ZAddSettingsFormSchema = z.object({
}),
});
type EnvelopeEditorSettingsTabType = 'general' | 'reminders' | 'email' | 'security';
type EnvelopeEditorSettingsTabType = 'general' | 'reminders' | 'notifications' | 'security';
const tabs = [
{
@@ -130,10 +131,10 @@ const tabs = [
description: msg`Configure signing reminder settings for the document.`,
},
{
id: 'email',
title: msg`Email`,
id: 'notifications',
title: msg`Notifications`,
icon: MailIcon,
description: msg`Configure email settings for the document.`,
description: msg`Configure notification settings for the document.`,
},
{
id: 'security',
@@ -143,6 +144,18 @@ const tabs = [
},
] as const;
// Recipient-facing notification events. These are suppressed at send time
// when distributionMethod is not EMAIL (see extractDerivedDocumentEmailSettings),
// so the UI mirrors that by disabling the matching checkboxes.
const RECIPIENT_EMAIL_EVENTS = [
DocumentEmailEvents.RecipientSigningRequest,
DocumentEmailEvents.RecipientRemoved,
DocumentEmailEvents.RecipientSigned,
DocumentEmailEvents.DocumentPending,
DocumentEmailEvents.DocumentCompleted,
DocumentEmailEvents.DocumentDeleted,
] as const;
type TAddSettingsFormSchema = z.infer<typeof ZAddSettingsFormSchema>;
type EnvelopeEditorSettingsDialogProps = {
@@ -205,6 +218,8 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
);
const emailSettings = form.watch('meta.emailSettings');
const distributionMethod = form.watch('meta.distributionMethod');
const isEmailDistribution = distributionMethod === DocumentDistributionMethod.EMAIL;
const { data: emailData, isLoading: isLoadingEmails } = trpc.enterprise.organisation.email.find.useQuery(
{
@@ -334,7 +349,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
{tabs.map((tab) => {
if (tab.id === 'email' && !settings.allowConfigureDistribution) {
if (tab.id === 'notifications' && !settings.allowConfigureDistribution) {
return null;
}
@@ -730,7 +745,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
)}
/>
))
.with({ activeTab: 'email', settings: { allowConfigureDistribution: true } }, () => (
.with({ activeTab: 'notifications', settings: { allowConfigureDistribution: true } }, () => (
<>
{settings.allowConfigureEmailSender && organisation.organisationClaim.flags.emailDomains && (
<FormField
@@ -747,6 +762,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
disabled={!isEmailDistribution}
>
<SelectTrigger loading={isLoadingEmails} className="bg-background">
<SelectValue />
@@ -783,7 +799,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} disabled={!isEmailDistribution} />
</FormControl>
<FormMessage />
@@ -804,7 +820,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} disabled={!isEmailDistribution} />
</FormControl>
<FormMessage />
@@ -832,7 +848,11 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
</FormLabel>
<FormControl>
<Textarea className="h-16 resize-none bg-background" {...field} />
<Textarea
className="h-16 resize-none bg-background"
{...field}
disabled={!isEmailDistribution}
/>
</FormControl>
<FormMessage />
@@ -843,7 +863,19 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
hiddenEvents={isEmailDistribution ? undefined : RECIPIENT_EMAIL_EVENTS}
/>
{!isEmailDistribution && (
<Alert variant="warning">
<AlertDescription>
<Trans>
Email distribution needs to be enabled in the general settings tab to configure recipient
email related settings.
</Trans>
</AlertDescription>
</Alert>
)}
</>
))
.with({ activeTab: 'security' }, () => (
@@ -9,6 +9,10 @@ import { isBase64Image } from '@documenso/lib/constants/signatures';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { ZFullFieldSchema } from '@documenso/lib/types/field';
import {
createFieldCanvasStyleCache,
type FieldCanvasStyleCache,
} from '@documenso/lib/universal/field-renderer/field-canvas-style';
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
@@ -135,7 +139,10 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
});
}, [envelope.recipients, pageNumber, currentEnvelopeItem?.id]);
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
const unsafeRenderFieldOnLayer = (
unparsedField: Field & { signature?: Signature | null },
fieldCanvasStyleCache: FieldCanvasStyleCache,
) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
@@ -143,11 +150,9 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
const fieldToRender = ZFullFieldSchema.parse(unparsedField);
const color = fieldToRender.fieldMeta?.readOnly
? 'readOnly'
: showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender)
? 'orange'
: 'green';
const isValidating = showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender);
const color = fieldToRender.fieldMeta?.readOnly ? 'readOnly' : isValidating ? 'orange' : 'green';
const { fieldGroup } = renderField({
scale,
@@ -159,6 +164,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
height: Number(fieldToRender.height),
positionX: Number(fieldToRender.positionX),
positionY: Number(fieldToRender.positionY),
isValidating,
signature: unparsedField.signature,
},
translations: getClientSideFieldTranslations(i18n),
@@ -166,6 +172,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
pageHeight: unscaledViewport.height,
color,
mode: 'sign',
fieldCanvasStyleCache,
});
const handleFieldGroupClick = (e: KonvaEventObject<Event>) => {
@@ -411,9 +418,12 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
fieldGroup.on('pointerdown', handleFieldGroupClick);
};
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
const renderFieldOnLayer = (
unparsedField: Field & { signature?: Signature | null },
fieldCanvasStyleCache: FieldCanvasStyleCache,
) => {
try {
unsafeRenderFieldOnLayer(unparsedField);
unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache);
} catch (err) {
console.error(err);
setRenderError(true);
@@ -426,6 +436,8 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
return;
}
const fieldCanvasStyleCache = createFieldCanvasStyleCache();
// Render current recipient fields which have changed or are not currently rendered.
for (const field of localPageFields) {
const existingCachedField = cachedRenderFields.current.get(field.id);
@@ -437,7 +449,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
existingCachedField.inserted !== field.inserted ||
existingCachedField.customText !== field.customText
) {
renderFieldOnLayer(field);
renderFieldOnLayer(field, fieldCanvasStyleCache);
cachedRenderFields.current.set(field.id, field);
}
}
@@ -463,6 +475,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
color: 'readOnly',
editable: false,
mode: 'sign',
fieldCanvasStyleCache,
});
// Other-recipient fields are display-only — they have no click handlers
@@ -5,7 +5,7 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getAllowedUploadMimeTypes } from '@documenso/lib/constants/document-conversion';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { AppError } from '@documenso/lib/errors/app-error';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
@@ -20,9 +20,9 @@ import { Loader } from 'lucide-react';
import { type ReactNode, useState } from 'react';
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { Link, useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentTeam } from '~/providers/team';
import { getUploadErrorMessage } from '~/utils/toast-error-messages';
export interface EnvelopeDropZoneWrapperProps {
children: ReactNode;
@@ -109,27 +109,11 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`)
.with('UNSUPPORTED_FILE_TYPE', () => t`This file type isn't supported. Please upload a PDF or Word document.`)
.with(
'CONVERSION_SERVICE_UNAVAILABLE',
() => t`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
)
.with(
'CONVERSION_FAILED',
() => t`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
)
.otherwise(() => t`An error occurred during upload.`);
const errorMessage = getUploadErrorMessage(error.code);
toast({
title: t`Error`,
description: errorMessage,
title: i18n._(errorMessage.title),
description: i18n._(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
@@ -2,7 +2,7 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { AppError } from '@documenso/lib/errors/app-error';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
@@ -17,9 +17,9 @@ import { EnvelopeType } from '@prisma/client';
import { useMemo, useState } from 'react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentTeam } from '~/providers/team';
import { getUploadErrorMessage } from '~/utils/toast-error-messages';
export type EnvelopeUploadButtonProps = {
className?: string;
@@ -112,27 +112,11 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
console.error(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`)
.with('UNSUPPORTED_FILE_TYPE', () => t`This file type isn't supported. Please upload a PDF or Word document.`)
.with(
'CONVERSION_SERVICE_UNAVAILABLE',
() => t`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
)
.with(
'CONVERSION_FAILED',
() => t`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
)
.otherwise(() => t`An error occurred while uploading your document.`);
const errorMessage = getUploadErrorMessage(error.code);
toast({
title: t`Error`,
description: errorMessage,
title: i18n._(errorMessage.title),
description: i18n._(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
@@ -0,0 +1,124 @@
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle } from 'lucide-react';
import { useState } from 'react';
export const OrganisationQuotaBanner = () => {
const [isOpen, setIsOpen] = useState(false);
const organisation = useOptionalCurrentOrganisation();
const { data: quotaFlags } = trpc.organisation.getQuotaFlags.useQuery(
{ organisationId: organisation?.id ?? '' },
{
enabled: Boolean(organisation),
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
...SKIP_QUERY_BATCH_META,
refetchInterval: 1000 * 60,
refetchIntervalInBackground: false,
},
);
const isAnyQuotaExceeded = Boolean(
quotaFlags?.isDocumentQuotaExceeded || quotaFlags?.isEmailQuotaExceeded || quotaFlags?.isApiQuotaExceeded,
);
// Every member of the organisation sees the banner when a quota is exhausted.
// Note: Skipping free plan banner for now because their quota can incorrectly show as exceeded.
if (
!organisation ||
!quotaFlags ||
!isAnyQuotaExceeded ||
organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE
) {
return null;
}
return (
<>
<div className="bg-yellow-200 text-yellow-900 dark:bg-yellow-400">
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 font-medium text-sm">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
<Trans>Your organisation has exceeded a fair use limit</Trans>
</div>
<Button
variant="outline"
className="text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500"
onClick={() => setIsOpen(true)}
size="sm"
>
<Trans>Learn more</Trans>
</Button>
</div>
</div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Fair use limit exceeded</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Your organisation has exceeded a fair use limit. Please contact{' '}
<a className="text-primary" href={`mailto:${SUPPORT_EMAIL}`}>
support
</a>{' '}
to review your plan's limits.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription>
<ul className="list-inside list-disc text-sm">
{quotaFlags.isDocumentQuotaExceeded && (
<li className="list-disc">
<Trans>Document creation has been temporarily paused</Trans>
</li>
)}
{quotaFlags.isEmailQuotaExceeded && (
<li className="list-disc">
<Trans>Email sending has been temporarily paused</Trans>
</li>
)}
{quotaFlags.isApiQuotaExceeded && (
<li className="list-disc">
<Trans>API requests have been temporarily paused</Trans>
</li>
)}
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">
<Trans>Close</Trans>
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};