Merge branch 'main' into exp/autoplace-fields

This commit is contained in:
Ephraim Duncan
2025-11-19 00:44:51 +00:00
committed by GitHub
213 changed files with 1920 additions and 1439 deletions

View File

@ -1,7 +1,7 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@ -57,6 +57,7 @@ export function AssistantConfirmationDialog({
allowDictateNextSigner = false,
defaultNextSigner,
}: ConfirmationDialogProps) {
const { t } = useLingui();
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
@ -146,7 +147,7 @@ export function AssistantConfirmationDialog({
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
placeholder={t`Enter the next signer's name`}
/>
</FormControl>
@ -168,7 +169,7 @@ export function AssistantConfirmationDialog({
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
placeholder={t`Enter the next signer's email`}
/>
</FormControl>
<FormMessage />

View File

@ -126,7 +126,7 @@ export const EnvelopeDownloadDialog = ({
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex w-full flex-col gap-4 overflow-hidden">
{isLoadingEnvelopeItems ? (
<>
{Array.from({ length: 1 }).map((_, index) => (
@ -159,7 +159,9 @@ export const EnvelopeDownloadDialog = ({
<div className="min-w-0 flex-1">
{/* Todo: Envelopes - Fix overflow */}
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
<h4 className="text-foreground truncate text-sm font-medium" title={item.title}>
{item.title}
</h4>
<p className="text-muted-foreground mt-0.5 text-xs">
<Trans>PDF Document</Trans>
</p>

View File

@ -1,8 +1,7 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
@ -149,7 +148,7 @@ export const OrganisationEmailCreateDialog = ({
<Trans>Display Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder="Support" />
<Input {...field} placeholder={t`Support`} />
</FormControl>
<FormMessage />
<FormDescription>
@ -175,7 +174,7 @@ export const OrganisationEmailCreateDialog = ({
onChange={(e) => {
field.onChange(e.target.value + '@' + emailDomain.domain);
}}
placeholder="support"
placeholder={t`support`}
/>
<div className="bg-muted text-muted-foreground absolute bottom-0 right-0 top-0 flex items-center rounded-r-md border px-3 py-2 text-sm">
@{emailDomain.domain}

View File

@ -1,8 +1,7 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
@ -134,7 +133,7 @@ export const OrganisationEmailUpdateDialog = ({
<Trans>Display Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder="Support" />
<Input {...field} placeholder={t`Support`} />
</FormControl>
<FormMessage />
<FormDescription>

View File

@ -1,9 +1,7 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { startRegistration } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
@ -54,7 +52,7 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
const [open, setOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const { _ } = useLingui();
const { t } = useLingui();
const { toast } = useToast();
const form = useForm<TCreatePasskeyFormSchema>({
@ -83,7 +81,7 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
});
toast({
description: _(msg`Successfully created passkey`),
description: t`Successfully created passkey`,
duration: 5000,
});
@ -176,7 +174,7 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
<Trans>Passkey name</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" placeholder="eg. Mac" {...field} />
<Input className="bg-background" placeholder={t`eg. Mac`} {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@ -1,9 +1,7 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Plus } from 'lucide-react';
import { useForm } from 'react-hook-form';
@ -49,7 +47,7 @@ type TCreateTeamEmailFormSchema = z.infer<typeof ZCreateTeamEmailFormSchema>;
export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { t } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
@ -73,8 +71,8 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
});
toast({
title: _(msg`Success`),
description: _(msg`We have sent a confirmation email for verification.`),
title: t`Success`,
description: t`We have sent a confirmation email for verification.`,
duration: 5000,
});
@ -87,17 +85,15 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
if (error.code === AppErrorCode.ALREADY_EXISTS) {
form.setError('email', {
type: 'manual',
message: _(msg`This email is already being used by another team.`),
message: t`This email is already being used by another team.`,
});
return;
}
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to add this email. Please try again later.`,
),
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to add this email. Please try again later.`,
variant: 'destructive',
});
}
@ -150,7 +146,7 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" placeholder="eg. Legal" {...field} />
<Input className="bg-background" placeholder={t`eg. Legal`} {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@ -1,9 +1,7 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TeamEmail } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
@ -50,7 +48,7 @@ export const TeamEmailUpdateDialog = ({
}: TeamEmailUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { t } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
@ -73,8 +71,8 @@ export const TeamEmailUpdateDialog = ({
});
toast({
title: _(msg`Success`),
description: _(msg`Team email was updated.`),
title: t`Success`,
description: t`Team email was updated.`,
duration: 5000,
});
@ -83,10 +81,8 @@ export const TeamEmailUpdateDialog = ({
setOpen(false);
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting update the team email. Please try again later.`,
),
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting update the team email. Please try again later.`,
variant: 'destructive',
});
}
@ -138,7 +134,7 @@ export const TeamEmailUpdateDialog = ({
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" placeholder="eg. Legal" {...field} />
<Input className="bg-background" placeholder={t`eg. Legal`} {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@ -1,9 +1,7 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Webhook } from '@prisma/client';
import { WebhookTriggerEvents } from '@prisma/client';
import { useForm } from 'react-hook-form';
@ -52,7 +50,7 @@ const ZTestWebhookFormSchema = z.object({
type TTestWebhookFormSchema = z.infer<typeof ZTestWebhookFormSchema>;
export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps) => {
const { _ } = useLingui();
const { t } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
@ -77,18 +75,16 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
});
toast({
title: _(msg`Test webhook sent`),
description: _(msg`The test webhook has been successfully sent to your endpoint.`),
title: t`Test webhook sent`,
description: t`The test webhook has been successfully sent to your endpoint.`,
duration: 5000,
});
setOpen(false);
} catch (error) {
toast({
title: _(msg`Test webhook failed`),
description: _(
msg`We encountered an error while sending the test webhook. Please check your endpoint and try again.`,
),
title: t`Test webhook failed`,
description: t`We encountered an error while sending the test webhook. Please check your endpoint and try again.`,
variant: 'destructive',
duration: 5000,
});
@ -129,7 +125,7 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an event type" />
<SelectValue placeholder={t`Select an event type`} />
</SelectTrigger>
</FormControl>
<SelectContent>

View File

@ -1,5 +1,4 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentDistributionMethod } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import type { Control } from 'react-hook-form';
@ -45,7 +44,7 @@ export const ConfigureDocumentAdvancedSettings = ({
control,
isSubmitting,
}: ConfigureDocumentAdvancedSettingsProps) => {
const { _ } = useLingui();
const { t } = useLingui();
const form = useFormContext<TConfigureEmbedFormSchema>();
const { features } = useConfigureDocument();
@ -82,33 +81,6 @@ export const ConfigureDocumentAdvancedSettings = ({
<TabsContent value="general" className="mt-0">
<div className="flex flex-col space-y-6">
{/* <FormField
control={control}
name="meta.externalId"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>External ID</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
Add an external ID to the document. This can be used to identify the
document in external systems.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} disabled={isSubmitting} />
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
{features.allowConfigureSignatureTypes && (
<FormField
control={control}
@ -121,13 +93,13 @@ export const ConfigureDocumentAdvancedSettings = ({
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: _(option.label),
label: t(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
emptySelectionPlaceholder="Select signature types"
emptySelectionPlaceholder={t`Select signature types`}
/>
</FormControl>
<FormMessage />

View File

@ -66,14 +66,13 @@ export const ConfigureDocumentRecipients = ({
});
const onAddSigner = useCallback(() => {
const signerNumber = signers.length + 1;
const recipientSigningOrder =
signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1;
appendSigner({
formId: nanoid(8),
name: isTemplate ? `Recipient ${signerNumber}` : '',
email: isTemplate ? `recipient.${signerNumber}@document.com` : '',
name: '',
email: '',
role: RecipientRole.SIGNER,
signingOrder:
signingOrder === DocumentSigningOrder.SEQUENTIAL ? recipientSigningOrder : undefined,

View File

@ -25,9 +25,11 @@ import { ConfigureDocumentUpload } from './configure-document-upload';
import {
type TConfigureEmbedFormSchema,
ZConfigureEmbedFormSchema,
ZConfigureTemplateEmbedFormSchema,
} from './configure-document-view.types';
export interface ConfigureDocumentViewProps {
type?: 'document' | 'template';
onSubmit: (data: TConfigureEmbedFormSchema) => void | Promise<void>;
defaultValues?: Partial<TConfigureEmbedFormSchema>;
disableUpload?: boolean;
@ -35,6 +37,7 @@ export interface ConfigureDocumentViewProps {
}
export const ConfigureDocumentView = ({
type = 'document',
onSubmit,
defaultValues,
disableUpload,
@ -42,14 +45,16 @@ export const ConfigureDocumentView = ({
const { isTemplate } = useConfigureDocument();
const form = useForm<TConfigureEmbedFormSchema>({
resolver: zodResolver(ZConfigureEmbedFormSchema),
resolver: zodResolver(
type === 'template' ? ZConfigureTemplateEmbedFormSchema : ZConfigureEmbedFormSchema,
),
defaultValues: {
title: defaultValues?.title || '',
signers: defaultValues?.signers || [
{
formId: nanoid(8),
name: isTemplate ? `Recipient ${1}` : '',
email: isTemplate ? `recipient.${1}@document.com` : '',
name: '',
email: '',
role: RecipientRole.SIGNER,
signingOrder: 1,
disabled: false,

View File

@ -17,7 +17,7 @@ export const ZConfigureEmbedFormSchema = z.object({
z.object({
nativeId: z.number().optional(),
formId: z.string(),
name: z.string().min(1, { message: 'Name is required' }),
name: z.string(),
email: z.string().email('Invalid email address'),
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(),
@ -48,3 +48,17 @@ export const ZConfigureEmbedFormSchema = z.object({
})
.optional(),
});
export const ZConfigureTemplateEmbedFormSchema = ZConfigureEmbedFormSchema.extend({
signers: z.array(
z.object({
nativeId: z.number().optional(),
formId: z.string(),
name: z.string(),
email: z.union([z.string().length(0), z.string().email('Invalid email address')]),
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(),
disabled: z.boolean().optional(),
}),
),
});

View File

@ -42,7 +42,7 @@ export type ConfigureFieldsViewProps = {
configData: TConfigureEmbedFormSchema;
documentData?: DocumentData;
defaultValues?: Partial<TConfigureFieldsFormSchema>;
onBack: (data: TConfigureFieldsFormSchema) => void;
onBack?: (data: TConfigureFieldsFormSchema) => void;
onSubmit: (data: TConfigureFieldsFormSchema) => void;
};
@ -481,15 +481,17 @@ export const ConfigureFieldsView = ({
</div>
<div className="mt-6 flex gap-2">
<Button
type="button"
variant="ghost"
className="flex-1"
loading={form.formState.isSubmitting}
onClick={() => onBack(form.getValues())}
>
<Trans>Back</Trans>
</Button>
{onBack && (
<Button
type="button"
variant="ghost"
className="flex-1"
loading={form.formState.isSubmitting}
onClick={() => onBack(form.getValues())}
>
<Trans>Back</Trans>
</Button>
)}
<Button
className="flex-1"
@ -642,15 +644,17 @@ export const ConfigureFieldsView = ({
</div>
<div className="mt-6 flex gap-2">
<Button
type="button"
variant="ghost"
className="flex-1"
loading={form.formState.isSubmitting}
onClick={() => onBack(form.getValues())}
>
<Trans>Back</Trans>
</Button>
{onBack && (
<Button
type="button"
variant="ghost"
className="flex-1"
loading={form.formState.isSubmitting}
onClick={() => onBack(form.getValues())}
>
<Trans>Back</Trans>
</Button>
)}
<Button
className="flex-1"

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@ -46,6 +46,8 @@ export const DocumentSigningAuthPassword = ({
open,
onOpenChange,
}: DocumentSigningAuthPasswordProps) => {
const { t } = useLingui();
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
@ -120,7 +122,7 @@ export const DocumentSigningAuthPassword = ({
<FormControl>
<Input
type="password"
placeholder="Enter your password"
placeholder={t`Enter your password`}
{...field}
autoComplete="current-password"
/>

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useSearchParams } from 'react-router';
@ -48,6 +48,7 @@ export function DocumentSigningRejectDialog({
onRejected,
trigger,
}: DocumentSigningRejectDialogProps) {
const { t } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@ -141,7 +142,7 @@ export function DocumentSigningRejectDialog({
<Textarea
{...field}
rows={4}
placeholder="Please provide a reason for rejecting this document"
placeholder={t`Please provide a reason for rejecting this document`}
disabled={form.formState.isSubmitting}
/>
</FormControl>

View File

@ -5,7 +5,7 @@ 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 } from 'lucide-react';
import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
@ -24,8 +24,10 @@ 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 { fieldButtonList } from './envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
export default function EnvelopeEditorFieldsPageRenderer() {
const { t, i18n } = useLingui();
@ -490,6 +492,18 @@ export default function EnvelopeEditorFieldsPageRenderer() {
setSelectedFields([]);
};
const changeSelectedFieldsRecipients = (recipientId: number) => {
const fields = selectedKonvaFieldGroups
.map((field) => editorFields.getFieldByFormId(field.id()))
.filter((field) => field !== undefined);
for (const field of fields) {
if (field.recipientId !== recipientId) {
editorFields.updateFieldByFormId(field.formId, { recipientId, id: undefined });
}
}
};
const duplicatedSelectedFields = () => {
const fields = selectedKonvaFieldGroups
.map((field) => editorFields.getFieldByFormId(field.id()))
@ -574,7 +588,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
{selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current &&
!isFieldChanging && (
<div
<FieldActionButtons
handleDuplicateSelectedFields={duplicatedSelectedFields}
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
style={{
position: 'absolute',
top:
@ -591,35 +610,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
pointerEvents: 'auto',
zIndex: 50,
}}
className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5"
>
<button
title={t`Duplicate`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={() => duplicatedSelectedFields()}
onTouchEnd={() => duplicatedSelectedFields()}
>
<CopyPlusIcon className="h-3 w-3" />
</button>
<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={() => duplicatedSelectedFieldsOnAllPages()}
onTouchEnd={() => duplicatedSelectedFieldsOnAllPages()}
>
<SquareStackIcon className="h-3 w-3" />
</button>
<button
title={t`Remove`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={() => deletedSelectedFields()}
onTouchEnd={() => deletedSelectedFields()}
>
<TrashIcon className="h-3 w-3" />
</button>
</div>
/>
)}
{pendingFieldCreation && (
@ -666,3 +657,119 @@ export default function EnvelopeEditorFieldsPageRenderer() {
</div>
);
}
type FieldActionButtonsProps = React.HTMLAttributes<HTMLDivElement> & {
handleDuplicateSelectedFields: () => void;
handleDuplicateSelectedFieldsOnAllPages: () => void;
handleDeleteSelectedFields: () => void;
handleChangeRecipient: (recipientId: number) => void;
selectedFieldFormId: string[];
};
const FieldActionButtons = ({
handleDuplicateSelectedFields,
handleDuplicateSelectedFieldsOnAllPages,
handleDeleteSelectedFields,
handleChangeRecipient,
selectedFieldFormId,
...props
}: FieldActionButtonsProps) => {
const { t } = useLingui();
const [showRecipientSelector, setShowRecipientSelector] = useState(false);
const { editorFields, envelope } = useCurrentEnvelopeEditor();
/**
* Decide the preselected recipient in the command input.
*
* If all fields belong to the same recipient then use that recipient as the default.
*
* Otherwise show the placeholder.
*/
const preselectedRecipient = useMemo(() => {
if (selectedFieldFormId.length === 0) {
return null;
}
const fields = editorFields.localFields.filter((field) =>
selectedFieldFormId.includes(field.formId),
);
const recipient = envelope.recipients.find(
(recipient) => recipient.id === fields[0].recipientId,
);
if (!recipient) {
return null;
}
const isRecipientsSame = fields.every((field) => field.recipientId === recipient.id);
if (isRecipientsSame) {
return recipient;
}
return null;
}, [editorFields.localFields]);
return (
<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
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)}
onTouchEnd={() => setShowRecipientSelector(true)}
>
<UserCircleIcon className="h-3 w-3" />
</button>
<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}
onTouchEnd={handleDuplicateSelectedFields}
>
<CopyPlusIcon className="h-3 w-3" />
</button>
<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}
onTouchEnd={handleDuplicateSelectedFieldsOnAllPages}
>
<SquareStackIcon className="h-3 w-3" />
</button>
<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}
onTouchEnd={handleDeleteSelectedFields}
>
<TrashIcon className="h-3 w-3" />
</button>
</div>
<CommandDialog
position="start"
open={showRecipientSelector}
onOpenChange={setShowRecipientSelector}
>
<EnvelopeRecipientSelectorCommand
placeholder={t`Select a recipient`}
selectedRecipient={preselectedRecipient}
onSelectedRecipientChange={(recipient) => {
editorFields.setSelectedRecipient(recipient.id);
handleChangeRecipient(recipient.id);
setShowRecipientSelector(false);
}}
recipients={envelope.recipients}
fields={envelope.fields}
/>
</CommandDialog>
</div>
);
};

View File

@ -5,7 +5,7 @@ import { msg, plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { Link } from 'react-router';
import { Link, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@ -32,7 +32,6 @@ import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animat
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -49,6 +48,7 @@ import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
const EnvelopeEditorFieldsPageRenderer = lazy(
async () => import('./envelope-editor-fields-page-renderer'),
@ -171,6 +171,8 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
};
export const EnvelopeEditorFieldsPage = () => {
const [searchParams] = useSearchParams();
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -407,12 +409,13 @@ export const EnvelopeEditorFieldsPage = () => {
<Trans>Selected Recipient</Trans>
</h3>
<RecipientSelector
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) =>
editorFields.setSelectedRecipient(recipient.id)
}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
/>
@ -591,6 +594,37 @@ export const EnvelopeEditorFieldsPage = () => {
<section>
<Separator className="my-4" />
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="text-foreground mb-3 text-sm font-semibold">
<Trans>Developer Mode</Trans>
</h3>
<div className="bg-muted/50 border-border text-foreground space-y-2 rounded-md border p-3 text-sm">
<p>
<span className="text-muted-foreground min-w-12">Pos X:&nbsp;</span>
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="text-muted-foreground min-w-12">Pos Y:&nbsp;</span>
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="text-muted-foreground min-w-12">Width:&nbsp;</span>
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="text-muted-foreground min-w-12">Height:&nbsp;</span>
{selectedField.height.toFixed(2)}
</p>
</div>
</div>
<Separator className="my-4" />
</>
)}
<div className="[&_label]:text-foreground/70 px-4 [&_label]:text-xs">
<h3 className="text-sm font-semibold">
{t(FieldSettingsTypeTranslations[selectedField.type])}

View File

@ -233,7 +233,19 @@ export const EnvelopeEditorSettingsDialog = ({
const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility);
const onFormSubmit = async (data: TAddSettingsFormSchema) => {
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
const {
timezone,
dateFormat,
redirectUrl,
language,
signatureTypes,
distributionMethod,
emailId,
emailSettings,
message,
subject,
emailReplyTo,
} = data.meta;
const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema)
@ -251,10 +263,16 @@ export const EnvelopeEditorSettingsDialog = ({
timezone,
dateFormat,
redirectUrl,
emailId,
message,
subject,
emailReplyTo,
emailSettings,
distributionMethod,
language: isValidLanguageCode(language) ? language : undefined,
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
},
});

View File

@ -0,0 +1,252 @@
import { useCallback, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import { RecipientRole, SendStatus } from '@prisma/client';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { sortBy } from 'remeda';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export interface EnvelopeRecipientSelectorProps {
className?: string;
selectedRecipient: Recipient | null;
onSelectedRecipientChange: (recipient: Recipient) => void;
recipients: Recipient[];
fields: Field[];
align?: 'center' | 'end' | 'start';
}
export const EnvelopeRecipientSelector = ({
className,
selectedRecipient,
onSelectedRecipientChange,
recipients,
fields,
align = 'start',
}: EnvelopeRecipientSelectorProps) => {
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
return (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === selectedRecipient?.id),
0,
),
).comboxBoxTrigger,
className,
)}
>
{selectedRecipient?.email && (
<span className="flex-1 truncate text-left">
{selectedRecipient?.name} ({selectedRecipient?.email})
</span>
)}
{!selectedRecipient?.email && (
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align={align}>
<EnvelopeRecipientSelectorCommand
fields={fields}
selectedRecipient={selectedRecipient}
onSelectedRecipientChange={(recipient) => {
onSelectedRecipientChange(recipient);
setShowRecipientsSelector(false);
}}
recipients={recipients}
/>
</PopoverContent>
</Popover>
);
};
interface EnvelopeRecipientSelectorCommandProps {
className?: string;
selectedRecipient: Recipient | null;
onSelectedRecipientChange: (recipient: Recipient) => void;
recipients: Recipient[];
fields: Field[];
placeholder?: string;
}
export const EnvelopeRecipientSelectorCommand = ({
className,
selectedRecipient,
onSelectedRecipientChange,
recipients,
fields,
placeholder,
}: EnvelopeRecipientSelectorCommandProps) => {
const { t } = useLingui();
const recipientsByRole = useCallback(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
CC: [],
VIEWER: [],
SIGNER: [],
APPROVER: [],
ASSISTANT: [],
};
recipients.forEach((recipient) => {
recipientsByRole[recipient.role].push(recipient);
});
return recipientsByRole;
}, [recipients]);
const recipientsByRoleToDisplay = useCallback(() => {
return Object.entries(recipientsByRole())
.filter(
([role]) =>
role !== RecipientRole.CC &&
role !== RecipientRole.VIEWER &&
role !== RecipientRole.ASSISTANT,
)
.map(
([role, roleRecipients]) =>
[
role,
sortBy(
roleRecipients,
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[(r) => r.id, 'asc'],
),
] as [RecipientRole, Recipient[]],
);
}, [recipientsByRole]);
const isRecipientDisabled = useCallback(
(recipientId: number) => {
const recipient = recipients.find((r) => r.id === recipientId);
const recipientFields = fields.filter((f) => f.recipientId === recipientId);
return !recipient || !canRecipientFieldsBeModified(recipient, recipientFields);
},
[fields, recipients],
);
return (
<Command
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
className={className}
>
<CommandInput placeholder={placeholder} />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
<Trans>No recipient matching this description was found.</Trans>
</span>
</CommandEmpty>
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
</div>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
>
<Trans>No recipients with this role</Trans>
</div>
)}
{roleRecipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn(
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === recipient.id),
0,
),
).comboxBoxItem,
{
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
'cursor-not-allowed': isRecipientDisabled(recipient.id),
},
)}
onSelect={() => {
if (!isRecipientDisabled(recipient.id)) {
onSelectedRecipientChange(recipient);
}
}}
>
<span
className={cn('text-foreground/70 truncate', {
'text-foreground/80': recipient.id === selectedRecipient?.id,
'opacity-50': isRecipientDisabled(recipient.id),
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
</span>
<div className="ml-auto flex items-center justify-center">
{!isRecipientDisabled(recipient.id) ? (
<Check
aria-hidden={recipient.id !== selectedRecipient?.id}
className={cn('h-4 w-4 flex-shrink-0', {
'opacity-0': recipient.id !== selectedRecipient?.id,
'opacity-100': recipient.id === selectedRecipient?.id,
})}
/>
) : (
<Tooltip>
<TooltipTrigger disabled={false}>
<Info className="z-50 ml-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
This document has already been sent to this recipient. You can no longer
edit this recipient.
</Trans>
</TooltipContent>
</Tooltip>
)}
</div>
</CommandItem>
))}
</CommandGroup>
))}
</Command>
);
};

View File

@ -282,18 +282,6 @@ export const OrgMenuSwitcher = () => {
</DropdownMenuItem>
)}
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/settings/profile">
<Trans>Account</Trans>
</Link>
</DropdownMenuItem>
{currentOrganisation &&
canExecuteOrganisationAction(
'MANAGE_ORGANISATION',
@ -314,6 +302,18 @@ export const OrgMenuSwitcher = () => {
</DropdownMenuItem>
)}
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/settings/profile">
<Trans>Account</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-muted-foreground px-4 py-2"
onClick={() => setLanguageSwitcherOpen(true)}

View File

@ -3,6 +3,8 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@ -15,6 +17,8 @@ export type OrganisationBillingPortalButtonProps = {
export const OrganisationBillingPortalButton = ({
buttonProps,
}: OrganisationBillingPortalButtonProps) => {
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const { _ } = useLingui();
@ -30,7 +34,10 @@ export const OrganisationBillingPortalButton = ({
const handleCreatePortal = async () => {
try {
const { redirectUrl } = await manageSubscription({ organisationId: organisation.id });
const { redirectUrl } = await manageSubscription({
organisationId: organisation.id,
isPersonalLayoutMode: isPersonalLayout(organisations),
});
window.open(redirectUrl, '_blank');
} catch (err) {

View File

@ -16,7 +16,7 @@ import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -29,6 +29,10 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
const isPersonalLayoutMode = isPersonalLayout(organisations);
const hasManageableBillingOrgs = organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
<Link to="/settings/profile">
@ -127,21 +131,6 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
<Trans>Webhooks</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && (
<Link to="/settings/billing">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/billing') && 'bg-secondary',
)}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
</>
)}
@ -158,6 +147,21 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
</Button>
</Link>
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/billing') && 'bg-secondary',
)}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
<Link to="/settings/security">
<Button
variant="ghost"

View File

@ -17,7 +17,7 @@ import { Link, useLocation } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -30,6 +30,10 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
const isPersonalLayoutMode = isPersonalLayout(organisations);
const hasManageableBillingOrgs = organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
return (
<div
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
@ -127,21 +131,6 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
<Trans>Webhooks</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && (
<Link to="/settings/billing">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/billing') && 'bg-secondary',
)}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
</>
)}
@ -158,6 +147,21 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
</Button>
</Link>
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/billing') && 'bg-secondary',
)}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
<Link to="/settings/security">
<Button
variant="ghost"

View File

@ -191,7 +191,7 @@ export const SettingsSecurityPasskeyTableActions = ({
<DialogDescription className="mt-4">
<Trans>
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey.
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey?
</Trans>
</DialogDescription>
</DialogHeader>

View File

@ -0,0 +1,111 @@
import { useMemo } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
export const UserBillingOrganisationsTable = () => {
const { t } = useLingui();
const { organisations } = useSession();
const billingOrganisations = useMemo(() => {
return organisations.filter((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
}, [organisations]);
const getSubscriptionStatusDisplay = (status: SubscriptionStatus | undefined) => {
return match(status)
.with(SubscriptionStatus.ACTIVE, () => ({
label: t`Active`,
variant: 'default' as const,
}))
.with(SubscriptionStatus.PAST_DUE, () => ({
label: t`Past Due`,
variant: 'warning' as const,
}))
.with(SubscriptionStatus.INACTIVE, () => ({
label: t`Inactive`,
variant: 'neutral' as const,
}))
.otherwise(() => ({
label: t`Free`,
variant: 'neutral' as const,
}));
};
const columns = useMemo(() => {
return [
{
header: t`Organisation`,
accessorKey: 'name',
cell: ({ row }) => (
<Link to={`/o/${row.original.url}`} preventScrollReset={true}>
<AvatarWithText
avatarSrc={formatAvatarUrl(row.original.avatarImageId)}
avatarClass="h-12 w-12"
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
primaryText={
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
}
secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/o/${row.original.url}`}
/>
</Link>
),
},
{
header: t`Subscription Status`,
accessorKey: 'subscription',
cell: ({ row }) => {
const subscription = row.original.subscription;
const status = subscription?.status;
const { label, variant } = getSubscriptionStatusDisplay(status);
return <Badge variant={variant}>{label}</Badge>;
},
},
{
header: t`Actions`,
id: 'actions',
cell: ({ row }) => (
<Button asChild variant="outline">
<Link to={`/o/${row.original.url}/settings/billing`}>
<Trans>Manage Billing</Trans>
</Link>
</Button>
),
},
] satisfies DataTableColumnDef<(typeof billingOrganisations)[number]>[];
}, [billingOrganisations]);
if (billingOrganisations.length === 0) {
return (
<div className="text-muted-foreground flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-center">
<p className="text-sm">
<Trans>You don't manage billing for any organisations.</Trans>
</p>
</div>
);
}
return (
<DataTable
columns={columns}
data={billingOrganisations}
perPage={billingOrganisations.length}
currentPage={1}
totalPages={1}
/>
);
};

View File

@ -0,0 +1,24 @@
import { useLingui } from '@lingui/react/macro';
import { SettingsHeader } from '~/components/general/settings-header';
import { UserBillingOrganisationsTable } from '~/components/tables/user-billing-organisations-table';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Billing');
}
export default function SettingsBilling() {
const { t } = useLingui();
return (
<div>
<SettingsHeader
title={t`Billing`}
subtitle={t`Manage billing and subscriptions for organisations where you have billing management permissions.`}
/>
<UserBillingOrganisationsTable />
</div>
);
}

View File

@ -36,14 +36,14 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
}
return {
hasValidToken: !!result,
token,
hasValidToken: !!result,
allowEmbedAuthoringWhiteLabel,
};
};
export default function AuthoringLayout() {
const { hasValidToken, token, allowEmbedAuthoringWhiteLabel } = useLoaderData<typeof loader>();
const { token, hasValidToken, allowEmbedAuthoringWhiteLabel } = useLoaderData<typeof loader>();
useLayoutEffect(() => {
try {

View File

@ -27,7 +27,7 @@ import { ConfigureFieldsView } from '~/components/embed/authoring/configure-fiel
import type { TConfigureFieldsFormSchema } from '~/components/embed/authoring/configure-fields-view.types';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringEditSchema,
} from '~/types/embed-authoring-base-schema';
import type { Route } from './+types/document.edit.$id';
@ -88,6 +88,8 @@ export default function EmbeddingAuthoringDocumentEditPage() {
const { document } = useLoaderData<typeof loader>();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const signatureTypes = useMemo(() => {
const types: string[] = [];
@ -159,6 +161,7 @@ export default function EmbeddingAuthoringDocumentEditPage() {
const [features, setFeatures] = useState<TBaseEmbedAuthoringSchema['features'] | null>(null);
const [externalId, setExternalId] = useState<string | null>(null);
const [currentStep, setCurrentStep] = useState(1);
const [canGoBack, setCanGoBack] = useState(true);
const { mutateAsync: updateEmbeddingDocument } =
trpc.embeddingPresign.updateEmbeddingDocument.useMutation();
@ -177,6 +180,7 @@ export default function EmbeddingAuthoringDocumentEditPage() {
fields: fieldData.fields.filter((field) => signerEmails.includes(field.signerEmail)),
};
});
setCurrentStep(2);
};
@ -275,7 +279,7 @@ export default function EmbeddingAuthoringDocumentEditPage() {
try {
const hash = window.location.hash.slice(1);
const result = ZBaseEmbedAuthoringSchema.safeParse(
const result = ZBaseEmbedAuthoringEditSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
@ -285,15 +289,26 @@ export default function EmbeddingAuthoringDocumentEditPage() {
setFeatures(result.data.features);
if (result.data.onlyEditFields) {
setCurrentStep(2);
setCanGoBack(false);
}
// Extract externalId from the parsed data if available
if (result.data.externalId) {
setExternalId(result.data.externalId);
}
setHasFinishedInit(true);
} catch (err) {
console.error('Error parsing embedding params:', err);
}
}, []);
if (!hasFinishedInit) {
return null;
}
return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg p-6">
<ConfigureDocumentProvider isTemplate={false} features={features ?? {}}>
@ -308,7 +323,7 @@ export default function EmbeddingAuthoringDocumentEditPage() {
configData={configuration!}
documentData={document.documentData}
defaultValues={fields ?? undefined}
onBack={handleBackToConfig}
onBack={canGoBack ? handleBackToConfig : undefined}
onSubmit={handleConfigureFieldsSubmit}
/>
</Stepper>

View File

@ -154,6 +154,7 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
<ConfigureDocumentProvider isTemplate={true} features={features ?? {}}>
<Stepper currentStep={currentStep} setCurrentStep={setCurrentStep}>
<ConfigureDocumentView
type="template"
defaultValues={configuration ?? undefined}
onSubmit={handleConfigurePageViewSubmit}
/>

View File

@ -27,7 +27,7 @@ import { ConfigureFieldsView } from '~/components/embed/authoring/configure-fiel
import type { TConfigureFieldsFormSchema } from '~/components/embed/authoring/configure-fields-view.types';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringEditSchema,
} from '~/types/embed-authoring-base-schema';
import type { Route } from './+types/document.edit.$id';
@ -88,6 +88,8 @@ export default function EmbeddingAuthoringTemplateEditPage() {
const { template } = useLoaderData<typeof loader>();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const signatureTypes = useMemo(() => {
const types: string[] = [];
@ -159,6 +161,7 @@ export default function EmbeddingAuthoringTemplateEditPage() {
const [features, setFeatures] = useState<TBaseEmbedAuthoringSchema['features'] | null>(null);
const [externalId, setExternalId] = useState<string | null>(null);
const [currentStep, setCurrentStep] = useState(1);
const [canGoBack, setCanGoBack] = useState(true);
const { mutateAsync: updateEmbeddingTemplate } =
trpc.embeddingPresign.updateEmbeddingTemplate.useMutation();
@ -230,7 +233,9 @@ export default function EmbeddingAuthoringTemplateEditPage() {
signingOrder: signer.signingOrder,
fields: fields
.filter((field) => field.signerEmail === signer.email)
.map((f) => ({
// There's a gnarly discriminated union that makes this hard to satisfy, we're casting for the second
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map<any>((f) => ({
...f,
id: f.nativeId,
envelopeItemId: template.templateDocumentData.envelopeItemId,
@ -273,7 +278,7 @@ export default function EmbeddingAuthoringTemplateEditPage() {
try {
const hash = window.location.hash.slice(1);
const result = ZBaseEmbedAuthoringSchema.safeParse(
const result = ZBaseEmbedAuthoringEditSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
@ -283,20 +288,32 @@ export default function EmbeddingAuthoringTemplateEditPage() {
setFeatures(result.data.features);
if (result.data.onlyEditFields) {
setCurrentStep(2);
setCanGoBack(false);
}
// Extract externalId from the parsed data if available
if (result.data.externalId) {
setExternalId(result.data.externalId);
}
setHasFinishedInit(true);
} catch (err) {
console.error('Error parsing embedding params:', err);
}
}, []);
if (!hasFinishedInit) {
return null;
}
return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg p-6">
<ConfigureDocumentProvider isTemplate={false} features={features ?? {}}>
<Stepper currentStep={currentStep} setCurrentStep={setCurrentStep}>
<ConfigureDocumentView
type="template"
defaultValues={configuration ?? undefined}
disableUpload={true}
onSubmit={handleConfigurePageViewSubmit}
@ -306,7 +323,7 @@ export default function EmbeddingAuthoringTemplateEditPage() {
configData={configuration!}
documentData={template.templateDocumentData}
defaultValues={fields ?? undefined}
onBack={handleBackToConfig}
onBack={canGoBack ? handleBackToConfig : undefined}
onSubmit={handleConfigureFieldsSubmit}
/>
</Stepper>

View File

@ -2,21 +2,24 @@ import { z } from 'zod';
import { ZBaseEmbedDataSchema } from './embed-base-schemas';
export const ZBaseEmbedAuthoringSchema = z
.object({
externalId: z.string().optional(),
features: z
.object({
allowConfigureSignatureTypes: z.boolean().optional(),
allowConfigureLanguage: z.boolean().optional(),
allowConfigureDateFormat: z.boolean().optional(),
allowConfigureTimezone: z.boolean().optional(),
allowConfigureRedirectUrl: z.boolean().optional(),
allowConfigureCommunication: z.boolean().optional(),
})
.optional()
.default({}),
})
.and(ZBaseEmbedDataSchema);
export const ZBaseEmbedAuthoringSchema = ZBaseEmbedDataSchema.extend({
externalId: z.string().optional(),
features: z
.object({
allowConfigureSignatureTypes: z.boolean().optional(),
allowConfigureLanguage: z.boolean().optional(),
allowConfigureDateFormat: z.boolean().optional(),
allowConfigureTimezone: z.boolean().optional(),
allowConfigureRedirectUrl: z.boolean().optional(),
allowConfigureCommunication: z.boolean().optional(),
})
.optional()
.default({}),
});
export const ZBaseEmbedAuthoringEditSchema = ZBaseEmbedAuthoringSchema.extend({
onlyEditFields: z.boolean().optional().default(false),
});
export type TBaseEmbedAuthoringSchema = z.infer<typeof ZBaseEmbedAuthoringSchema>;
export type TBaseEmbedAuthoringEditSchema = z.infer<typeof ZBaseEmbedAuthoringEditSchema>;