Merge branch 'main' into feat/document-file-conversion

Resolved conflicts:
- create-document-data.ts: merged initialData + originalData/originalMimeType
- files.helpers.ts: kept both imports
- envelope-drop-zone-wrapper.tsx: adopted buildDropzoneRejectionDescription
- create-envelope-items.ts: adopted UNSAFE_createEnvelopeItems
- create-envelope.ts: integrated convertToPdfIfNeeded into createEnvelopeRouteCaller

Extended putPdfFileServerSide to accept { initialData, originalData,
originalMimeType } options; updated seal-document.handler call site.
This commit is contained in:
ephraimduncan
2026-04-20 10:11:35 +00:00
1127 changed files with 107456 additions and 22991 deletions
@@ -45,7 +45,7 @@ export const LanguageSwitcherDialog = ({ open, setOpen }: LanguageSwitcherDialog
{Object.values(SUPPORTED_LANGUAGES).map((language) => (
<CommandItem
key={language.short}
value={language.full}
value={_(language.full)}
onSelect={async () => setLanguage(language.short)}
>
<CheckIcon
@@ -54,7 +54,7 @@ export const LanguageSwitcherDialog = ({ open, setOpen }: LanguageSwitcherDialog
i18n.locale === language.short ? 'opacity-100' : 'opacity-0',
)}
/>
{SUPPORTED_LANGUAGES[language.short].full}
{_(language.full)}
</CommandItem>
))}
</CommandGroup>
@@ -0,0 +1,125 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { CheckIcon, CopyIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { cn } from '../../lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover';
const HOVER_DELAY_MS = 500;
export type LocalTimeProps = {
date: Date | string;
className?: string;
};
export const LocalTime = ({ date, className }: LocalTimeProps) => {
const [open, setOpen] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const isMouseOver = useRef(false);
const dt = useMemo(() => DateTime.fromJSDate(new Date(date)), [date]);
const relative = dt.toRelative() ?? '';
const local = dt.toFormat('yyyy-MM-dd HH:mm:ss ZZZZ');
const utc = dt.toUTC().toFormat('yyyy-MM-dd HH:mm:ss') + ' UTC';
const unix = Math.floor(dt.toSeconds()).toString();
const onMouseEnter = useCallback(() => {
isMouseOver.current = true;
hoverTimeout.current = setTimeout(() => {
if (isMouseOver.current) {
setOpen(true);
}
}, HOVER_DELAY_MS);
}, []);
const onMouseLeave = useCallback(() => {
isMouseOver.current = false;
if (hoverTimeout.current) {
clearTimeout(hoverTimeout.current);
hoverTimeout.current = null;
}
setTimeout(() => {
if (!isMouseOver.current) {
setOpen(false);
}
}, 200);
}, []);
const onCopy = async (label: string, value: string) => {
await navigator.clipboard.writeText(value);
setCopiedField(label);
setTimeout(() => setCopiedField(null), 1500);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={cn(
'cursor-pointer underline decoration-muted-foreground/50 decoration-dotted underline-offset-4',
className,
)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{relative}
</PopoverTrigger>
<PopoverContent
className="w-auto p-3"
align="start"
onMouseEnter={() => {
isMouseOver.current = true;
}}
onMouseLeave={onMouseLeave}
>
<div className="space-y-1.5 text-xs">
<TimeRow
label="Local"
value={local}
isCopied={copiedField === 'Local'}
onCopy={() => void onCopy('Local', local)}
/>
<TimeRow
label="UTC"
value={utc}
isCopied={copiedField === 'UTC'}
onCopy={() => void onCopy('UTC', utc)}
/>
<TimeRow
label="Unix"
value={unix}
isCopied={copiedField === 'Unix'}
onCopy={() => void onCopy('Unix', unix)}
/>
</div>
</PopoverContent>
</Popover>
);
};
type TimeRowProps = {
label: string;
value: string;
isCopied: boolean;
onCopy: () => void;
};
const TimeRow = ({ label, value, isCopied, onCopy }: TimeRowProps) => (
<div className="flex items-center justify-between gap-x-4">
<div>
<span className="text-muted-foreground">{label}: </span>
<span className="font-mono">{value}</span>
</div>
<button type="button" className="text-muted-foreground hover:text-foreground" onClick={onCopy}>
{isCopied ? <CheckIcon className="h-3 w-3" /> : <CopyIcon className="h-3 w-3" />}
</button>
</div>
);
@@ -34,17 +34,17 @@ export const DocumentEmailCheckboxes = ({
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.RecipientSigned}
>
<Trans>Send recipient signed email</Trans>
<Trans>Email the owner when a recipient signs</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Recipient signed email</Trans>
@@ -72,17 +72,17 @@ export const DocumentEmailCheckboxes = ({
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.RecipientSigningRequest}
>
<Trans>Send recipient signing request email</Trans>
<Trans>Email recipients with a signing request</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Recipient signing request email</Trans>
@@ -110,17 +110,17 @@ export const DocumentEmailCheckboxes = ({
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.RecipientRemoved}
>
<Trans>Send recipient removed email</Trans>
<Trans>Email recipients when they're removed from a pending document</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Recipient removed email</Trans>
@@ -148,17 +148,17 @@ export const DocumentEmailCheckboxes = ({
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.DocumentPending}
>
<Trans>Send document pending email</Trans>
<Trans>Email the signer if the document is still pending</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document pending email</Trans>
@@ -187,17 +187,17 @@ export const DocumentEmailCheckboxes = ({
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.DocumentCompleted}
>
<Trans>Send document completed email</Trans>
<Trans>Email recipients when the document is completed</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document completed email</Trans>
@@ -225,17 +225,17 @@ export const DocumentEmailCheckboxes = ({
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.DocumentDeleted}
>
<Trans>Send document deleted email</Trans>
<Trans>Email recipients when a pending document is deleted</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document deleted email</Trans>
@@ -263,17 +263,17 @@ export const DocumentEmailCheckboxes = ({
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.OwnerDocumentCompleted}
>
<Trans>Send document completed email to the owner</Trans>
<Trans>Email the owner when the document is completed</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document completed email</Trans>
@@ -290,6 +290,84 @@ export const DocumentEmailCheckboxes = ({
</Tooltip>
</label>
</div>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.OwnerDocumentCreated}
className="h-5 w-5"
checked={value.ownerDocumentCreated}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.OwnerDocumentCreated]: Boolean(checked) })
}
/>
<label
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.OwnerDocumentCreated}
>
<Trans>Email the owner when a document is created from a direct template</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document created from direct template email</Trans>
</strong>
</h2>
<p>
<Trans>
This email is sent to the document owner when a recipient creates a document via a
direct template link.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.OwnerRecipientExpired}
className="h-5 w-5"
checked={value.ownerRecipientExpired}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.OwnerRecipientExpired]: Boolean(checked) })
}
/>
<label
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.OwnerRecipientExpired}
>
<Trans>Send recipient expired email to the owner</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Recipient expired email</Trans>
</strong>
</h2>
<p>
<Trans>
This will be sent to the document owner when a recipient's signing window has
expired.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</div>
);
};
@@ -2,12 +2,13 @@ import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, Field, Recipient } from '@prisma/client';
import type { DocumentMeta, Field } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
@@ -34,7 +35,7 @@ const getRecipientDisplayText = (recipient: { name: string; email: string }) =>
};
export type DocumentField = Field & {
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus'>;
recipient: Pick<TRecipientLite, 'name' | 'email' | 'signingStatus'>;
};
export type DocumentReadOnlyFieldsProps = {
@@ -68,7 +69,7 @@ export type DocumentReadOnlyFieldsProps = {
export const mapFieldsWithRecipients = (
fields: Field[],
recipients: Recipient[],
recipients: TRecipientLite[],
): DocumentField[] => {
return fields.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
@@ -98,10 +99,8 @@ export const DocumentReadOnlyFields = ({
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
const highestPageNumber = Math.max(...fields.map((field) => field.page));
return (
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
@@ -112,10 +111,7 @@ export const DocumentReadOnlyFields = ({
color={
showRecipientColors
? getRecipientColorStyles(
Math.max(
recipientIds.findIndex((id) => id === field.recipientId),
0,
),
recipientIds.findIndex((id) => id === field.recipientId),
)
: undefined
}
@@ -163,7 +159,7 @@ export const DocumentReadOnlyFields = ({
</span>
</p>
<p className="text-muted-foreground mt-1 text-center text-xs">
<p className="mt-1 text-center text-xs text-muted-foreground">
{getRecipientDisplayText(field.recipient)}
</p>
@@ -9,6 +9,7 @@ import { FaXTwitter } from 'react-icons/fa6';
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent';
import { trpc } from '@documenso/trpc/react';
@@ -60,7 +61,9 @@ export const DocumentShareButton = ({
mutateAsync: createOrGetShareLink,
data: shareLink,
isPending: isCreatingOrGettingShareLink,
} = trpc.document.share.useMutation();
} = trpc.document.share.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
});
const isLoading = isCreatingOrGettingShareLink || isCopyingShareLink;
@@ -138,7 +141,7 @@ export const DocumentShareButton = ({
<DialogContent position="end">
<DialogHeader>
<DialogTitle>
<DialogTitle className="w-full max-w-full whitespace-pre-line break-words">
<Trans>Share your signing experience!</Trans>
</DialogTitle>
@@ -166,7 +169,7 @@ export const DocumentShareButton = ({
</span>
<div
className={cn(
'bg-muted/40 mt-4 aspect-[1200/630] overflow-hidden rounded-lg border',
'mt-4 aspect-[1200/630] overflow-hidden rounded-lg border bg-muted/40',
{
'animate-pulse': !shareLink?.slug,
},
@@ -186,10 +186,12 @@ export function EnvelopeRecipientFieldTooltip({
)}
<p className="text-center font-semibold">
<span>{t(FRIENDLY_FIELD_TYPE[field.type])} field</span>
<span>
<Trans>{t(FRIENDLY_FIELD_TYPE[field.type])} field</Trans>
</span>
</p>
<p className="text-muted-foreground mt-1 text-center text-xs">
<p className="mt-1 text-center text-xs text-muted-foreground">
{getRecipientDisplayText(field.recipient)}
</p>
@@ -0,0 +1,146 @@
import { Plural, Trans } from '@lingui/react/macro';
import type {
TEnvelopeExpirationDurationPeriod,
TEnvelopeExpirationPeriod,
} from '@documenso/lib/constants/envelope-expiration';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type ExpirationMode = 'duration' | 'disabled' | 'inherit';
const getMode = (value: TEnvelopeExpirationPeriod | null | undefined): ExpirationMode => {
if (!value) {
return 'inherit';
}
if ('disabled' in value) {
return 'disabled';
}
return 'duration';
};
const getAmount = (value: TEnvelopeExpirationPeriod | null | undefined): number => {
if (value && 'amount' in value) {
return value.amount;
}
return 1;
};
const getUnit = (
value: TEnvelopeExpirationPeriod | null | undefined,
): TEnvelopeExpirationDurationPeriod['unit'] => {
if (value && 'unit' in value) {
return value.unit;
}
return 'month';
};
export type ExpirationPeriodPickerProps = {
value: TEnvelopeExpirationPeriod | null | undefined;
onChange: (value: TEnvelopeExpirationPeriod | null) => void;
disabled?: boolean;
inheritLabel?: string;
};
export const ExpirationPeriodPicker = ({
value,
onChange,
disabled = false,
inheritLabel,
}: ExpirationPeriodPickerProps) => {
const mode = getMode(value);
const amount = getAmount(value);
const unit = getUnit(value);
const onModeChange = (newMode: string) => {
if (newMode === 'inherit') {
onChange(null);
return;
}
if (newMode === 'disabled') {
onChange({ disabled: true });
return;
}
onChange({ unit, amount });
};
const onAmountChange = (newAmount: number) => {
const clamped = Math.max(1, Math.floor(newAmount));
onChange({ unit, amount: clamped });
};
const onUnitChange = (newUnit: string) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onChange({ unit: newUnit as TEnvelopeExpirationDurationPeriod['unit'], amount });
};
return (
<div className="flex flex-col gap-2">
<Select value={mode} onValueChange={onModeChange} disabled={disabled}>
<SelectTrigger className="bg-background" data-testid="envelope-expiration-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="duration">
<Trans>Custom duration</Trans>
</SelectItem>
<SelectItem value="disabled">
<Trans>Never expires</Trans>
</SelectItem>
{inheritLabel !== undefined && <SelectItem value="inherit">{inheritLabel}</SelectItem>}
</SelectContent>
</Select>
{mode === 'duration' && (
<div className="flex flex-row gap-2">
<Input
type="number"
min={1}
className="w-20 bg-background"
data-testid="envelope-expiration-amount"
value={amount}
onChange={(e) => onAmountChange(Number(e.target.value))}
disabled={disabled}
/>
<Select value={unit} onValueChange={onUnitChange} disabled={disabled}>
<SelectTrigger className="flex-1 bg-background" data-testid="envelope-expiration-unit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">
<Plural value={amount} one="Day" other="Days" />
</SelectItem>
<SelectItem value="week">
<Plural value={amount} one="Week" other="Weeks" />
</SelectItem>
<SelectItem value="month">
<Plural value={amount} one="Month" other="Months" />
</SelectItem>
<SelectItem value="year">
<Plural value={amount} one="Year" other="Years" />
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
);
};
@@ -0,0 +1,266 @@
import { Plural, Trans } from '@lingui/react/macro';
import type {
TEnvelopeReminderDurationPeriod,
TEnvelopeReminderPeriod,
TEnvelopeReminderSettings,
} from '@documenso/lib/constants/envelope-reminder';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type ReminderMode = 'enabled' | 'disabled' | 'inherit';
const getMode = (value: TEnvelopeReminderSettings | null | undefined): ReminderMode => {
if (value === null || value === undefined) {
return 'inherit';
}
if ('disabled' in value.sendAfter) {
return 'disabled';
}
return 'enabled';
};
const getPeriodAmount = (period: TEnvelopeReminderPeriod | undefined): number => {
if (period && 'amount' in period) {
return period.amount;
}
return 1;
};
const getPeriodUnit = (
period: TEnvelopeReminderPeriod | undefined,
): TEnvelopeReminderDurationPeriod['unit'] => {
if (period && 'unit' in period) {
return period.unit;
}
return 'day';
};
export type ReminderSettingsPickerProps = {
value: TEnvelopeReminderSettings | null | undefined;
onChange: (value: TEnvelopeReminderSettings | null) => void;
disabled?: boolean;
inheritLabel?: string;
};
export const ReminderSettingsPicker = ({
value,
onChange,
disabled = false,
inheritLabel,
}: ReminderSettingsPickerProps) => {
const mode = getMode(value);
const sendAfterAmount = getPeriodAmount(value?.sendAfter);
const sendAfterUnit = getPeriodUnit(value?.sendAfter);
const repeatEveryAmount = getPeriodAmount(value?.repeatEvery);
const repeatEveryUnit = getPeriodUnit(value?.repeatEvery);
const onModeChange = (newMode: string) => {
if (newMode === 'inherit') {
onChange(null);
return;
}
if (newMode === 'disabled') {
onChange({
sendAfter: { disabled: true },
repeatEvery: { disabled: true },
});
return;
}
onChange({
sendAfter: { unit: sendAfterUnit, amount: sendAfterAmount },
repeatEvery: { unit: repeatEveryUnit, amount: repeatEveryAmount },
});
};
const updateSendAfter = (
updates: Partial<{ amount: number; unit: TEnvelopeReminderDurationPeriod['unit'] }>,
) => {
const newAmount = Math.max(1, Math.floor(updates.amount ?? sendAfterAmount));
const newUnit = updates.unit ?? sendAfterUnit;
onChange({
sendAfter: { unit: newUnit, amount: newAmount },
repeatEvery: value?.repeatEvery ?? { unit: repeatEveryUnit, amount: repeatEveryAmount },
});
};
const updateRepeatEvery = (
updates: Partial<{ amount: number; unit: TEnvelopeReminderDurationPeriod['unit'] }>,
) => {
const newAmount = Math.max(1, Math.floor(updates.amount ?? repeatEveryAmount));
const newUnit = updates.unit ?? repeatEveryUnit;
onChange({
sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount },
repeatEvery: { unit: newUnit, amount: newAmount },
});
};
const onRepeatModeChange = (newMode: string) => {
if (newMode === 'disabled') {
onChange({
sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount },
repeatEvery: { disabled: true },
});
return;
}
onChange({
sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount },
repeatEvery: { unit: repeatEveryUnit, amount: repeatEveryAmount },
});
};
const repeatMode = value?.repeatEvery && 'disabled' in value.repeatEvery ? 'disabled' : 'enabled';
return (
<div className="flex flex-col gap-4" data-testid="reminder-settings-picker">
<Select value={mode} onValueChange={onModeChange} disabled={disabled}>
<SelectTrigger className="bg-background" data-testid="reminder-mode-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">
<Trans>Enabled</Trans>
</SelectItem>
<SelectItem value="disabled">
<Trans>No reminders</Trans>
</SelectItem>
{inheritLabel !== undefined && <SelectItem value="inherit">{inheritLabel}</SelectItem>}
</SelectContent>
</Select>
{mode === 'enabled' && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label className="text-sm text-muted-foreground">
<Trans>Send first reminder after</Trans>
</Label>
<div className="flex flex-row gap-2">
<Input
type="number"
min={1}
className="w-20 bg-background"
value={sendAfterAmount}
onChange={(e) => updateSendAfter({ amount: Number(e.target.value) })}
disabled={disabled}
data-testid="reminder-send-after-amount"
/>
<UnitSelect
value={sendAfterUnit}
amount={sendAfterAmount}
onChange={(unit) =>
updateSendAfter({
unit: unit as TEnvelopeReminderDurationPeriod['unit'],
})
}
disabled={disabled}
testId="reminder-send-after-unit"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm text-muted-foreground">
<Trans>Then repeat every</Trans>
</Label>
<Select value={repeatMode} onValueChange={onRepeatModeChange} disabled={disabled}>
<SelectTrigger className="bg-background" data-testid="reminder-repeat-mode-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">
<Trans>Custom interval</Trans>
</SelectItem>
<SelectItem value="disabled">
<Trans>Don't repeat</Trans>
</SelectItem>
</SelectContent>
</Select>
{repeatMode === 'enabled' && (
<div className="flex flex-row gap-2">
<Input
type="number"
min={1}
className="w-20 bg-background"
value={repeatEveryAmount}
onChange={(e) => updateRepeatEvery({ amount: Number(e.target.value) })}
disabled={disabled}
data-testid="reminder-repeat-amount"
/>
<UnitSelect
value={repeatEveryUnit}
amount={repeatEveryAmount}
onChange={(unit) =>
updateRepeatEvery({
unit: unit as TEnvelopeReminderDurationPeriod['unit'],
})
}
disabled={disabled}
testId="reminder-repeat-unit"
/>
</div>
)}
</div>
</div>
)}
</div>
);
};
const UnitSelect = ({
value,
amount,
onChange,
disabled,
testId,
}: {
value: string;
amount: number;
onChange: (value: string) => void;
disabled: boolean;
testId: string;
}) => (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="flex-1 bg-background" data-testid={testId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">
<Plural value={amount} one="Day" other="Days" />
</SelectItem>
<SelectItem value="week">
<Plural value={amount} one="Week" other="Weeks" />
</SelectItem>
<SelectItem value="month">
<Plural value={amount} one="Month" other="Months" />
</SelectItem>
</SelectContent>
</Select>
);
@@ -124,7 +124,10 @@ export function EnvelopeFieldToolTip({
<Tooltip delayDuration={0} open={!field.inserted || !field.fieldMeta}>
<TooltipTrigger className="absolute inset-0 w-full"></TooltipTrigger>
<TooltipContent className={tooltipVariants({ color, className })} sideOffset={2}>
<TooltipContent
className={tooltipVariants({ color, className: cn(className, 'z-40') })}
sideOffset={2}
>
{children}
<TooltipArrow />
</TooltipContent>
@@ -65,7 +65,7 @@ export function FieldToolTip({ children, color, className = '', field }: FieldTo
<TooltipTrigger className="absolute inset-0 w-full"></TooltipTrigger>
<TooltipContent
className={tooltipVariants({ color, className })}
className={tooltipVariants({ color, className: cn(className, 'z-40') })}
sideOffset={2}
onClick={onTooltipContentClick}
>
+27 -2
View File
@@ -5,7 +5,11 @@ import { createPortal } from 'react-dom';
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { useIsPageInDom } from '@documenso/lib/client-only/hooks/use-is-page-in-dom';
import {
PDF_VIEWER_CONTENT_SELECTOR,
PDF_VIEWER_PAGE_SELECTOR,
} from '@documenso/lib/constants/pdf-viewer';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import type { RecipientColorStyles } from '../../lib/recipient-colors';
@@ -81,6 +85,8 @@ export function FieldRootContainer({
readonly,
}: FieldRootContainerProps) {
const [isValidating, setIsValidating] = useState(false);
const isPageInDom = useIsPageInDom(field.page);
const ref = React.useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -88,6 +94,21 @@ export function FieldRootContainer({
return;
}
// Check the validation signal on the PDF viewer container. When a field
// mounts after the virtual list scrolls to its page, the per-element
// `data-validate` attribute will not have been set yet. The signal on the
// `[data-pdf-content]` container bridges this gap so newly-rendered fields
// pick up the validation state immediately.
const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR);
if (
pdfContent?.getAttribute('data-validate-fields') === 'true' &&
isFieldUnsignedAndRequired(field)
) {
ref.current.setAttribute('data-validate', 'true');
setIsValidating(true);
}
const observer = new MutationObserver((_mutations) => {
if (ref.current) {
setIsValidating(ref.current.getAttribute('data-validate') === 'true');
@@ -101,7 +122,11 @@ export function FieldRootContainer({
return () => {
observer.disconnect();
};
}, []);
}, [isPageInDom]);
if (!isPageInDom) {
return null;
}
return (
<FieldContainerPortal field={field}>
@@ -1,26 +0,0 @@
import React, { Suspense, lazy } from 'react';
import { type PDFDocumentProxy } from 'pdfjs-dist';
import type { PdfViewerRendererMode } from './pdf-viewer-konva';
export type LoadedPDFDocument = PDFDocumentProxy;
export type PDFViewerProps = {
className?: string;
onDocumentLoad?: () => void;
renderer: PdfViewerRendererMode;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
const EnvelopePdfViewer = lazy(async () => import('./pdf-viewer-konva'));
export const PDFViewerKonvaLazy = (props: PDFViewerProps) => {
return (
<Suspense fallback={<div>Loading...</div>}>
<EnvelopePdfViewer {...props} />
</Suspense>
);
};
export default PDFViewerKonvaLazy;
@@ -1,213 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import Konva from 'konva';
import { Loader } from 'lucide-react';
import { type PDFDocumentProxy } from 'pdfjs-dist';
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
export type LoadedPDFDocument = PDFDocumentProxy;
/**
* This imports the worker from the `pdfjs-dist` package.
*/
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
const pdfViewerOptions = {
cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps/`,
};
const PDFLoader = () => (
<>
<Loader className="h-12 w-12 animate-spin text-documenso" />
<p className="mt-4 text-muted-foreground">
<Trans>Loading document...</Trans>
</p>
</>
);
export type PdfViewerRendererMode = 'editor' | 'preview' | 'signing';
const RendererErrorMessages: Record<
PdfViewerRendererMode,
{ title: MessageDescriptor; description: MessageDescriptor }
> = {
editor: {
title: msg`Configuration Error`,
description: msg`There was an issue rendering some fields, please review the fields and try again.`,
},
preview: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
signing: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
};
export type PdfViewerKonvaProps = {
className?: string;
onDocumentLoad?: () => void;
customPageRenderer?: React.FunctionComponent;
renderer: PdfViewerRendererMode;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
export const PdfViewerKonva = ({
className,
onDocumentLoad,
customPageRenderer,
renderer,
...props
}: PdfViewerKonvaProps) => {
const { t } = useLingui();
const $el = useRef<HTMLDivElement>(null);
const { getPdfBuffer, currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0);
const [pdfError, setPdfError] = useState(false);
const envelopeItemFile = useMemo(() => {
const data = getPdfBuffer(currentEnvelopeItem?.id || '');
if (!data || data.status !== 'loaded') {
return null;
}
return {
data: new Uint8Array(data.file),
};
}, [currentEnvelopeItem?.id, getPdfBuffer]);
const onDocumentLoaded = useCallback(
(doc: PDFDocumentProxy) => {
setNumPages(doc.numPages);
},
[onDocumentLoad],
);
useEffect(() => {
if ($el.current) {
const $current = $el.current;
const { width } = $current.getBoundingClientRect();
setWidth(width);
const onResize = () => {
const { width } = $current.getBoundingClientRect();
setWidth(width);
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}
}, []);
return (
<div ref={$el} className={cn('w-full max-w-[800px]', className)} {...props}>
{renderError && (
<Alert variant="destructive" className="mb-4 max-w-[800px]">
<AlertTitle>{t(RendererErrorMessages[renderer].title)}</AlertTitle>
<AlertDescription>{t(RendererErrorMessages[renderer].description)}</AlertDescription>
</Alert>
)}
{envelopeItemFile && Konva ? (
<PDFDocument
file={envelopeItemFile}
className={cn('w-full rounded', {
'h-[80vh] max-h-[60rem]': numPages === 0,
})}
onLoadSuccess={(d) => onDocumentLoaded(d)}
// Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop.
// Therefore we add some additional custom error handling.
onSourceError={() => {
setPdfError(true);
}}
externalLinkTarget="_blank"
loading={
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
{pdfError ? (
<div className="text-center text-muted-foreground">
<p>
<Trans>Something went wrong while loading the document.</Trans>
</p>
<p className="mt-1 text-sm">
<Trans>Please try again or contact our support.</Trans>
</p>
</div>
) : (
<PDFLoader />
)}
</div>
}
error={
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
<div className="text-center text-muted-foreground">
<p>
<Trans>Something went wrong while loading the document.</Trans>
</p>
<p className="mt-1 text-sm">
<Trans>Please try again or contact our support.</Trans>
</p>
</div>
</div>
}
options={pdfViewerOptions}
>
{Array(numPages)
.fill(null)
.map((_, i) => (
<div key={i} className="last:-mb-2">
<div className="rounded border border-border will-change-transform">
<PDFPage
pageNumber={i + 1}
width={width}
renderAnnotationLayer={false}
renderTextLayer={false}
loading={() => ''}
renderMode={customPageRenderer ? 'custom' : 'canvas'}
customRenderer={customPageRenderer}
/>
</div>
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
<Trans>
Page {i + 1} of {numPages}
</Trans>
</p>
</div>
))}
</PDFDocument>
) : (
<div
className={cn(
'flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden rounded',
)}
>
<PDFLoader />
</div>
)}
</div>
);
};
export default PdfViewerKonva;
@@ -12,14 +12,28 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
import { cn } from '../../lib/utils';
export type RecipientRoleSelectProps = SelectProps & {
hideCCRecipients?: boolean;
hideAssistantRole?: boolean;
hideCCerRole?: boolean;
hideViewerRole?: boolean;
hideApproverRole?: boolean;
isAssistantEnabled?: boolean;
};
export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSelectProps>(
({ hideCCRecipients, isAssistantEnabled = true, ...props }, ref) => (
(
{
hideAssistantRole,
hideCCerRole,
hideViewerRole,
hideApproverRole,
isAssistantEnabled = true,
...props
},
ref,
) => (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background w-[50px] p-2">
<SelectTrigger ref={ref} className="w-[50px] bg-background p-2" title={props.value}>
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
{ROLE_ICONS[props.value as RecipientRole]}
</SelectTrigger>
@@ -35,7 +49,7 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<p>
<Trans>
The recipient is required to sign the document for it to be completed.
@@ -46,49 +60,53 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
<Trans>Needs to approve</Trans>
{!hideApproverRole && (
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
<Trans>Needs to approve</Trans>
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<p>
<Trans>
The recipient is required to approve the document for it to be completed.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
<Trans>
The recipient is required to approve the document for it to be completed.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
</SelectItem>
)}
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
<Trans>Needs to view</Trans>
{!hideViewerRole && (
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
<Trans>Needs to view</Trans>
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<p>
<Trans>
The recipient is required to view the document for it to be completed.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
<Trans>
The recipient is required to view the document for it to be completed.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
</SelectItem>
)}
{!hideCCRecipients && (
{!hideCCerRole && (
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
@@ -99,7 +117,7 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<p>
<Trans>
The recipient is not required to take any action and receives a copy of the
@@ -112,41 +130,43 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
</SelectItem>
)}
<SelectItem
value={RecipientRole.ASSISTANT}
disabled={!isAssistantEnabled}
className={cn(
!isAssistantEnabled &&
'cursor-not-allowed opacity-50 data-[disabled]:pointer-events-auto',
)}
>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.ASSISTANT]}</span>
<Trans>Can prepare</Trans>
{!hideAssistantRole && (
<SelectItem
value={RecipientRole.ASSISTANT}
disabled={!isAssistantEnabled}
className={cn(
!isAssistantEnabled &&
'cursor-not-allowed opacity-50 data-[disabled]:pointer-events-auto',
)}
>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.ASSISTANT]}</span>
<Trans>Can prepare</Trans>
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<p>
{isAssistantEnabled ? (
<Trans>
The recipient can prepare the document for later signers by pre-filling
suggest values.
</Trans>
) : (
<Trans>
Assistant role is only available when the document is in sequential signing
mode.
</Trans>
)}
</p>
</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
{isAssistantEnabled ? (
<Trans>
The recipient can prepare the document for later signers by pre-filling
suggest values.
</Trans>
) : (
<Trans>
Assistant role is only available when the document is in sequential signing
mode.
</Trans>
)}
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
</SelectItem>
)}
</SelectContent>
</Select>
),
@@ -0,0 +1,76 @@
import React, { forwardRef } from 'react';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TemplateType } from '@prisma/client';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type TemplateTypeSelectProps = SelectProps;
export const TemplateTypeSelect = forwardRef<HTMLButtonElement, TemplateTypeSelectProps>(
({ ...props }, ref) => {
useLingui();
return (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={TemplateType.PRIVATE}>{t`Private`}</SelectItem>
<SelectItem value={TemplateType.PUBLIC}>{t`Public`}</SelectItem>
<SelectItem value={TemplateType.ORGANISATION}>{t`Organisation`}</SelectItem>
</SelectContent>
</Select>
);
},
);
TemplateTypeSelect.displayName = 'TemplateTypeSelect';
export const TemplateTypeTooltip = ({
organisationTeamCount,
}: {
organisationTeamCount: number;
}) => {
return (
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<p>
<Trans>
<strong>Private</strong> templates can only be used by your team.
</Trans>
</p>
<p>
<Trans>
<strong>Public</strong> templates are linked to your public profile.
</Trans>
</p>
{organisationTeamCount >= 2 && (
<p>
<Trans>
<strong>Organisation</strong> templates are shared across all teams in your
organisation but can only be edited by the owning team.
</Trans>
</p>
)}
</TooltipContent>
</Tooltip>
);
};
@@ -0,0 +1,19 @@
import { msg } from '@lingui/core/macro';
import { ErrorCode, type FileRejection } from 'react-dropzone';
import { match } from 'ts-pattern';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
export const buildDropzoneRejectionDescription = (fileRejections: FileRejection[]) => {
const errorCode = fileRejections[0]?.errors[0]?.code;
return match(errorCode)
.with(
ErrorCode.FileTooLarge,
() => msg`File is larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
)
.with(ErrorCode.FileInvalidType, () => msg`Only PDF files are allowed`)
.with(ErrorCode.FileTooSmall, () => msg`File is too small`)
.with(ErrorCode.TooManyFiles, () => msg`Only one file can be uploaded at a time`)
.otherwise(() => msg`Unknown error`);
};
+71 -112
View File
@@ -1,9 +1,7 @@
// !: We declare all of our classes here since TailwindCSS will remove any unused CSS classes,
// !: therefore doing this at runtime is not possible without whitelisting a set of classnames.
// !:
// !: This will later be improved as we move to a CSS variable approach and rotate the lightness
import { colord } from 'colord';
import { once } from 'remeda';
export type RecipientColorMap = Record<number, RecipientColorStyles>;
export type TRecipientColor = 'readOnly' | (typeof AVAILABLE_RECIPIENT_COLORS)[number];
export type RecipientColorStyles = {
base: string;
@@ -11,128 +9,89 @@ export type RecipientColorStyles = {
baseRingHover: string;
baseTextHover: string;
fieldButton: string;
fieldButtonText: string;
fieldItem: string;
fieldItemInitials: string;
comboxBoxTrigger: string;
comboxBoxItem: string;
comboBoxTrigger: string;
comboBoxItem: string;
};
export const DEFAULT_RECT_BACKGROUND = 'rgba(255, 255, 255, 0.95)';
// !: values of the declared variable to do all the background, border and shadow styles.
export const RECIPIENT_COLOR_STYLES = {
readOnly: {
const RECIPIENT_COLOR_STYLES: Record<TRecipientColor, () => RecipientColorStyles> = {
readOnly: (): RecipientColorStyles => ({
base: 'ring-neutral-400',
baseRing: 'rgba(176, 176, 176, 1)',
baseRingHover: 'rgba(176, 176, 176, 1)',
baseTextHover: 'rgba(176, 176, 176, 1)',
fieldButton: 'border-neutral-400 hover:border-neutral-400',
fieldButtonText: '',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: '',
comboxBoxTrigger:
comboBoxTrigger:
'ring-2 ring-recipient-green shadow-[0_0_0_5px_hsl(var(--recipient-green)/10%),0_0_0_2px_hsl(var(--recipient-green)/60%),0_0_0_0.5px_hsl(var(--recipient-green))]',
comboxBoxItem: '',
},
green: {
base: 'ring-recipient-green hover:bg-recipient-green/30',
baseRing: 'rgba(122, 195, 85, 1)',
baseRingHover: 'rgba(122, 195, 85, 0.3)',
baseTextHover: 'rgba(122, 195, 85, 1)',
fieldButton: 'hover:border-recipient-green hover:bg-recipient-green/30 ',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-green',
comboxBoxTrigger:
'ring-2 ring-recipient-green hover:bg-recipient-green/15 active:bg-recipient-green/15 shadow-[0_0_0_5px_hsl(var(--recipient-green)/10%),0_0_0_2px_hsl(var(--recipient-green)/60%),0_0_0_0.5px_hsl(var(--recipient-green))]',
comboxBoxItem: 'hover:bg-recipient-green/15 active:bg-recipient-green/15',
},
blue: {
base: 'ring-recipient-blue hover:bg-recipient-blue/30',
baseRing: 'rgba(56, 123, 199, 1)',
baseRingHover: 'rgba(56, 123, 199, 0.3)',
baseTextHover: 'rgba(56, 123, 199, 1)',
fieldButton: 'hover:border-recipient-blue hover:bg-recipient-blue/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-blue',
comboxBoxTrigger:
'ring-2 ring-recipient-blue hover:bg-recipient-blue/15 active:bg-recipient-blue/15 shadow-[0_0_0_5px_hsl(var(--recipient-blue)/10%),0_0_0_2px_hsl(var(--recipient-blue)/60%),0_0_0_0.5px_hsl(var(--recipient-blue))]',
comboxBoxItem: 'ring-recipient-blue hover:bg-recipient-blue/15 active:bg-recipient-blue/15',
},
purple: {
base: 'ring-recipient-purple hover:bg-recipient-purple/30',
baseRing: 'rgba(151, 71, 255, 1)',
baseRingHover: 'rgba(151, 71, 255, 0.3)',
baseTextHover: 'rgba(151, 71, 255, 1)',
fieldButton: 'hover:border-recipient-purple hover:bg-recipient-purple/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-purple',
comboxBoxTrigger:
'ring-2 ring-recipient-purple hover:bg-recipient-purple/15 active:bg-recipient-purple/15 shadow-[0_0_0_5px_hsl(var(--recipient-purple)/10%),0_0_0_2px_hsl(var(--recipient-purple)/60%),0_0_0_0.5px_hsl(var(--recipient-purple))]',
comboxBoxItem: 'hover:bg-recipient-purple/15 active:bg-recipient-purple/15',
},
orange: {
base: 'ring-recipient-orange hover:bg-recipient-orange/30',
baseRing: 'rgba(246, 159, 30, 1)',
baseRingHover: 'rgba(246, 159, 30, 0.3)',
baseTextHover: 'rgba(246, 159, 30, 1)',
fieldButton: 'hover:border-recipient-orange hover:bg-recipient-orange/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-orange',
comboxBoxTrigger:
'ring-2 ring-recipient-orange hover:bg-recipient-orange/15 active:bg-recipient-orange/15 shadow-[0_0_0_5px_hsl(var(--recipient-orange)/10%),0_0_0_2px_hsl(var(--recipient-orange)/60%),0_0_0_0.5px_hsl(var(--recipient-orange))]',
comboxBoxItem: 'hover:bg-recipient-orange/15 active:bg-recipient-orange/15',
},
yellow: {
base: 'ring-recipient-yellow hover:bg-recipient-yellow/30',
baseRing: 'rgba(219, 186, 0, 1)',
baseRingHover: 'rgba(219, 186, 0, 0.3)',
baseTextHover: 'rgba(219, 186, 0, 1)',
fieldButton: 'hover:border-recipient-yellow hover:bg-recipient-yellow/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-yellow',
comboxBoxTrigger:
'ring-2 ring-recipient-yellow hover:bg-recipient-yellow/15 active:bg-recipient-yellow/15 shadow-[0_0_0_5px_hsl(var(--recipient-yellow)/10%),0_0_0_2px_hsl(var(--recipient-yellow)/60%),0_0_0_0.5px_hsl(var(--recipient-yellow))]',
comboxBoxItem: 'hover:bg-recipient-yellow/15 active:bg-recipient-yellow/15',
},
pink: {
base: 'ring-recipient-pink hover:bg-recipient-pink/30',
baseRing: 'rgba(217, 74, 186, 1)',
baseRingHover: 'rgba(217, 74, 186, 0.3)',
baseTextHover: 'rgba(217, 74, 186, 1)',
fieldButton: 'hover:border-recipient-pink hover:bg-recipient-pink/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-pink',
comboxBoxTrigger:
'ring-2 ring-recipient-pink hover:bg-recipient-pink/15 active:bg-recipient-pink/15 shadow-[0_0_0_5px_hsl(var(--recipient-pink)/10%),0_0_0_2px_hsl(var(--recipient-pink)/60%),0_0_0_0.5px_hsl(var(--recipient-pink',
comboxBoxItem: 'hover:bg-recipient-pink/15 active:bg-recipient-pink/15',
},
} satisfies Record<string, RecipientColorStyles>;
export type TRecipientColor = keyof typeof RECIPIENT_COLOR_STYLES;
export const AVAILABLE_RECIPIENT_COLORS = [
'green',
'blue',
'purple',
'orange',
'yellow',
'pink',
] satisfies TRecipientColor[];
export const useRecipientColors = (index: number) => {
const key = AVAILABLE_RECIPIENT_COLORS[index % AVAILABLE_RECIPIENT_COLORS.length];
return RECIPIENT_COLOR_STYLES[key];
comboBoxItem: '',
}),
green: once(() => generateStyles('green')),
blue: once(() => generateStyles('blue')),
purple: once(() => generateStyles('purple')),
orange: once(() => generateStyles('orange')),
yellow: once(() => generateStyles('yellow')),
pink: once(() => generateStyles('pink')),
};
export const getRecipientColorStyles = (index: number) => {
// Disabling the rule since the hook doesn't do anything special and can
// be used universally.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRecipientColors(index);
const generateStyles = (recipientColor: TRecipientColor): RecipientColorStyles => {
const { bg, border, ring, text } = CSS_PROPERTY;
const { active, hover, groupHover, groupHoverFieldItem } = CSS_VARIANT;
const name = `recipient-${recipientColor}`;
const value = getComputedStyle(document.documentElement).getPropertyValue(`--${name}`);
const color = colord(`hsl(${value})`);
return {
base: `${ring}-${name} ${hover}:${bg}-${name}/30`,
baseRing: color.toRgbString(),
baseRingHover: color.alpha(0.3).toRgbString(),
baseTextHover: color.toRgbString(),
fieldButton: `${hover}:${border}-${name} ${hover}:${bg}-${name}/30`,
fieldButtonText: `${groupHover}:${text}-${name}`,
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: `${groupHoverFieldItem}:${bg}-${name}`,
comboBoxTrigger: `ring-2 ${ring}-${name} ${hover}:${bg}-${name}/15 ${active}:${bg}-${name}/15 shadow-[0_0_0_5px_hsl(var(--${name})/10%),0_0_0_2px_hsl(var(--${name})/60%),0_0_0_0.5px_hsl(var(--${name}))]`,
comboBoxItem: `${hover}:${bg}-${name}/15 ${active}:${bg}-${name}/15`,
};
};
const CSS_PROPERTY = {
bg: 'bg',
border: 'border',
ring: 'ring',
text: 'text',
};
const CSS_VARIANT = {
active: 'active',
groupHover: 'group-hover',
groupHoverFieldItem: 'group-hover/field-item',
hover: 'hover',
};
const AVAILABLE_RECIPIENT_COLORS = ['green', 'blue', 'purple', 'orange', 'yellow', 'pink'] as const;
export const RECIPIENT_DYNAMIC_CLASS = {
pattern: new RegExp(
`(${Object.values(CSS_PROPERTY).join('|')})-recipient-(${AVAILABLE_RECIPIENT_COLORS.join('|')})(\\/(15|30))?$`,
),
variants: Object.values(CSS_VARIANT),
};
export const getRecipientColor = (index: number) => {
return AVAILABLE_RECIPIENT_COLORS[Math.max(index, 0) % AVAILABLE_RECIPIENT_COLORS.length];
};
export const getRecipientColorStyles = (colorOrIndex: TRecipientColor | number) => {
const color = typeof colorOrIndex === 'number' ? getRecipientColor(colorOrIndex) : colorOrIndex;
return RECIPIENT_COLOR_STYLES[color]();
};
+1 -2
View File
@@ -66,14 +66,13 @@
"framer-motion": "^12.23.24",
"lucide-react": "^0.554.0",
"luxon": "^3.7.2",
"perfect-freehand": "^1.2.2",
"pdfjs-dist": "5.4.296",
"perfect-freehand": "^1.2.2",
"react": "^18",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18",
"react-hook-form": "^7.66.1",
"react-pdf": "^10.3.0",
"react-rnd": "^10.5.2",
"remeda": "^2.32.0",
"tailwind-merge": "^1.14.0",
+2
View File
@@ -20,6 +20,8 @@ const badgeVariants = cva(
'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20',
secondary:
'bg-blue-50 text-blue-700 ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30',
orange:
'bg-orange-50 text-orange-700 ring-orange-700/10 dark:bg-orange-400/10 dark:text-orange-400 dark:ring-orange-400/30',
},
size: {
small: 'px-1.5 py-0.5 text-xs',
+22 -2
View File
@@ -4,6 +4,7 @@ import { Trans } from '@lingui/react/macro';
import type {
ColumnDef,
PaginationState,
RowSelectionState,
Table as TTable,
Updater,
VisibilityState,
@@ -15,7 +16,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '.
export type DataTableChildren<TData> = (_table: TTable<TData>) => React.ReactNode;
export type { ColumnDef as DataTableColumnDef } from '@tanstack/react-table';
export type { ColumnDef as DataTableColumnDef, RowSelectionState } from '@tanstack/react-table';
export interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@@ -40,6 +41,10 @@ export interface DataTableProps<TData, TValue> {
enable: boolean;
component?: React.ReactNode;
};
enableRowSelection?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: (selection: RowSelectionState) => void;
getRowId?: (row: TData) => string;
}
export function DataTable<TData, TValue>({
@@ -58,6 +63,10 @@ export function DataTable<TData, TValue>({
rowClassName,
children,
emptyState,
enableRowSelection,
rowSelection,
onRowSelectionChange,
getRowId,
}: DataTableProps<TData, TValue>) {
const pagination = useMemo<PaginationState>(() => {
if (currentPage !== undefined && perPage !== undefined) {
@@ -85,6 +94,13 @@ export function DataTable<TData, TValue>({
}
};
const onTableRowSelectionChange = (updater: Updater<RowSelectionState>) => {
if (onRowSelectionChange) {
const newSelection = typeof updater === 'function' ? updater(rowSelection ?? {}) : updater;
onRowSelectionChange(newSelection);
}
};
const table = useReactTable({
data,
columns,
@@ -92,10 +108,14 @@ export function DataTable<TData, TValue>({
state: {
pagination: manualPagination ? pagination : undefined,
columnVisibility,
rowSelection: rowSelection ?? {},
},
manualPagination,
pageCount: totalPages,
onPaginationChange: onTablePaginationChange,
enableRowSelection,
onRowSelectionChange: onTableRowSelectionChange,
getRowId,
});
return (
@@ -162,7 +182,7 @@ export function DataTable<TData, TValue>({
{hasFilters && onClearFilters !== undefined && (
<button
onClick={() => onClearFilters()}
className="text-foreground mt-1 text-sm"
className="mt-1 text-sm text-foreground"
>
<Trans>Clear filters</Trans>
</button>
@@ -4,7 +4,7 @@ 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 { Field, Recipient } from '@prisma/client';
import type { Field } from '@prisma/client';
import { FieldType, Prisma, RecipientRole, SendStatus } from '@prisma/client';
import {
CalendarDays,
@@ -23,11 +23,12 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
import {
type TFieldMetaSchema as FieldMeta,
ZFieldMetaSchema,
} from '@documenso/lib/types/field-meta';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsUninserted } from '@documenso/lib/utils/fields';
@@ -35,10 +36,11 @@ import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import {
canRecipientBeModified,
canRecipientFieldsBeModified,
getRecipientsWithMissingFields,
} from '@documenso/lib/utils/recipients';
import { FieldToolTip } from '../../components/field/field-tooltip';
import { useRecipientColors } from '../../lib/recipient-colors';
import { getRecipientColorStyles } from '../../lib/recipient-colors';
import { cn } from '../../lib/utils';
import { Alert, AlertDescription } from '../alert';
import { Card, CardContent } from '../card';
@@ -82,7 +84,7 @@ export type FieldFormType = {
export type AddFieldsFormProps = {
documentFlow: DocumentFlowStep;
hideRecipients?: boolean;
recipients: Recipient[];
recipients: TRecipientLite[];
fields: Field[];
onSubmit: (_data: TAddFieldsFormSchema) => void;
onAutoSave: (_data: TAddFieldsFormSchema) => Promise<void>;
@@ -171,7 +173,7 @@ export const AddFieldsFormPartial = ({
});
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
const [selectedSigner, setSelectedSigner] = useState<TRecipientLite | null>(null);
const [lastActiveField, setLastActiveField] = useState<TAddFieldsFormSchema['fields'][0] | null>(
null,
);
@@ -179,9 +181,7 @@ export const AddFieldsFormPartial = ({
null,
);
const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
const selectedSignerStyles = useRecipientColors(
selectedSignerIndex === -1 ? 0 : selectedSignerIndex,
);
const selectedSignerStyles = getRecipientColorStyles(selectedSignerIndex);
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
@@ -430,13 +430,15 @@ export const AddFieldsFormPartial = ({
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
const totalPages = getPdfPagesCount();
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (totalPages < 1) {
return;
}
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
if (pageNumber === lastActiveField.pageNumber) {
return;
continue;
}
const newField: TAddFieldsFormSchema['fields'][0] = {
@@ -449,7 +451,7 @@ export const AddFieldsFormPartial = ({
};
append(newField);
});
}
return;
}
@@ -535,7 +537,7 @@ export const AddFieldsFormPartial = ({
}, [recipients]);
const recipientsByRole = useMemo(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
const recipientsByRole: Record<RecipientRole, TRecipientLite[]> = {
CC: [],
VIEWER: [],
SIGNER: [],
@@ -555,15 +557,11 @@ export const AddFieldsFormPartial = ({
};
const handleGoNextClick = () => {
const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) =>
localFields.some(
(field) =>
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
field.signerEmail === signer.email,
),
);
// localFields already have recipientId set correctly (see field creation at line 338)
// Using the existing recipientId is important for handling duplicate email recipients
const recipientsMissingFields = getRecipientsWithMissingFields(recipients, localFields);
if (!everySignerHasSignature) {
if (recipientsMissingFields.length > 0) {
setIsMissingSignatureDialogVisible(true);
return;
}
@@ -756,7 +754,7 @@ export const AddFieldsFormPartial = ({
)}
>
<Contact className="h-4 w-4" />
Initials
<Trans>Initials</Trans>
</p>
</CardContent>
</Card>
@@ -906,7 +904,7 @@ export const AddFieldsFormPartial = ({
)}
>
<Disc className="h-4 w-4" />
Radio
<Trans>Radio</Trans>
</p>
</CardContent>
</Card>
@@ -931,8 +929,7 @@ export const AddFieldsFormPartial = ({
)}
>
<CheckSquare className="h-4 w-4" />
{/* Not translated on purpose. */}
Checkbox
<Trans>Checkbox</Trans>
</p>
</CardContent>
</Card>
@@ -6,7 +6,6 @@ import {
DocumentStatus,
DocumentVisibility,
type Field,
type Recipient,
SendStatus,
TeamMemberRole,
} from '@prisma/client';
@@ -21,6 +20,7 @@ import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDocument } from '@documenso/lib/types/document';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import {
@@ -74,7 +74,7 @@ import type { DocumentFlowStep } from './types';
export type AddSettingsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
recipients: TRecipientLite[];
fields: Field[];
isDocumentPdfLoaded: boolean;
document: TDocument;
@@ -242,7 +242,7 @@ export const AddSettingsFormPartial = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Trans>
Controls the language for the document, including the language to be used
for email notifications, and the final certificate that is generated and
@@ -269,7 +269,7 @@ export const AddSettingsFormPartial = ({
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
{t(language.full)}
</SelectItem>
))}
</SelectContent>
@@ -361,11 +361,11 @@ export const AddSettingsFormPartial = ({
<Accordion type="multiple" className="mt-6">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="text-foreground mb-2 rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline">
<AccordionTrigger className="mb-2 rounded border px-3 py-2 text-left text-foreground hover:bg-neutral-200/30 hover:no-underline">
<Trans>Advanced Options</Trans>
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-2 text-sm leading-relaxed">
<AccordionContent className="-mx-1 px-1 pt-2 text-sm leading-relaxed text-muted-foreground">
<div className="flex flex-col space-y-6">
<FormField
control={form.control}
@@ -379,7 +379,7 @@ export const AddSettingsFormPartial = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Add an external ID to the document. This can be used to identify
the document in external systems.
@@ -418,7 +418,7 @@ export const AddSettingsFormPartial = ({
field.onChange(value);
void handleAutoSave();
}}
className="bg-background w-full"
className="w-full bg-background"
emptySelectionPlaceholder={t`Select signature types`}
/>
</FormControl>
@@ -506,7 +506,7 @@ export const AddSettingsFormPartial = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Add a URL to redirect the user to once the document is signed
</Trans>
@@ -6,7 +6,7 @@ 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 { Field, Recipient } from '@prisma/client';
import type { Field } from '@prisma/client';
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Plus, Trash } from 'lucide-react';
@@ -19,6 +19,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
@@ -54,12 +55,12 @@ import { SigningOrderConfirmation } from './signing-order-confirmation';
import type { DocumentFlowStep } from './types';
type AutoSaveResponse = {
recipients: Recipient[];
recipients: TRecipientLite[];
};
export type AddSignersFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
recipients: TRecipientLite[];
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
@@ -3,16 +3,14 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { zEmail } from '@documenso/lib/utils/zod';
export const ZAddSignersFormSchema = z.object({
signers: z.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
email: zEmail(msg`Invalid email`.id).min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -4,7 +4,7 @@ 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 { Field, Recipient } from '@prisma/client';
import type { Field } from '@prisma/client';
import { DocumentDistributionMethod, DocumentStatus, RecipientRole } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
@@ -15,6 +15,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
@@ -59,7 +60,7 @@ import type { DocumentFlowStep } from './types';
export type AddSubjectFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
recipients: TRecipientLite[];
fields: Field[];
document: TDocument;
onSubmit: (_data: TAddSubjectFormSchema) => void;
@@ -191,10 +192,10 @@ export const AddSubjectFormPartial = ({
>
<TabsList className="w-full">
<TabsTrigger className="w-full" value={DocumentDistributionMethod.EMAIL}>
Email
<Trans>Email</Trans>
</TabsTrigger>
<TabsTrigger className="w-full" value={DocumentDistributionMethod.NONE}>
None
<Trans>None</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
@@ -2,14 +2,12 @@ import { DocumentDistributionMethod } from '@prisma/client';
import { z } from 'zod';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { zEmail } from '@documenso/lib/utils/zod';
export const ZAddSubjectFormSchema = z.object({
meta: z.object({
emailId: z.string().nullable(),
emailReplyTo: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().email().optional(),
),
emailReplyTo: z.preprocess((val) => (val === '' ? undefined : val), zEmail().optional()),
// emailReplyName: z.string().optional(),
subject: z.string(),
message: z.string(),
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { CopyPlus, Settings2, SquareStack, Trash } from 'lucide-react';
import { createPortal } from 'react-dom';
@@ -9,11 +10,12 @@ import { Rnd } from 'react-rnd';
import { useSearchParams } from 'react-router';
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
import { useIsPageInDom } from '@documenso/lib/client-only/hooks/use-is-page-in-dom';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { useRecipientColors } from '../../lib/recipient-colors';
import { getRecipientColorStyles } from '../../lib/recipient-colors';
import { cn } from '../../lib/utils';
import { FieldContent } from './field-content';
import type { TDocumentFlowFormSchema } from './types';
@@ -49,7 +51,17 @@ export type FieldItemProps = {
/**
* The item when editing fields??
*/
export const FieldItem = ({
export const FieldItem = (props: FieldItemProps) => {
const isPageInDom = useIsPageInDom(props.field.pageNumber);
if (!isPageInDom) {
return null;
}
return <FieldItemInner {...props} />;
};
const FieldItemInner = ({
fieldClassName,
field,
passive,
@@ -88,7 +100,7 @@ export const FieldItem = ({
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
const signerStyles = useRecipientColors(recipientIndex);
const signerStyles = getRecipientColorStyles(recipientIndex);
const isDevMode = searchParams.get('devmode') === 'true';
@@ -321,22 +333,44 @@ export const FieldItem = ({
</div>
{isDevMode && (
<div className="absolute -top-20 left-1/2 z-50 -translate-x-1/2 rounded-md border border-border bg-background/95 px-2 py-1 shadow-sm backdrop-blur-sm">
<div className="absolute bottom-full left-1/2 z-50 mb-1 -translate-x-1/2 rounded-md border border-border bg-background/95 px-2 py-1 shadow-sm backdrop-blur-sm">
<div className="flex flex-col gap-0.5 text-[9px]">
{field.nativeId && (
<span>
<span className="text-muted-foreground">
<Trans>Field ID:</Trans>
</span>{' '}
<span className="font-mono text-foreground">{field.nativeId}</span>
</span>
)}
<span>
<span className="text-muted-foreground">Pos X:&nbsp;</span>
<span className="text-muted-foreground">
<Trans>Recipient ID:</Trans>
</span>{' '}
<span className="font-mono text-foreground">{field.recipientId}</span>
</span>
<span>
<span className="text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
<span className="font-mono text-foreground">{field.pageX.toFixed(2)}</span>
</span>
<span>
<span className="text-muted-foreground">Pos Y:&nbsp;</span>
<span className="text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
<span className="font-mono text-foreground">{field.pageY.toFixed(2)}</span>
</span>
<span>
<span className="text-muted-foreground">Width:&nbsp;</span>
<span className="text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
<span className="font-mono text-foreground">{field.pageWidth.toFixed(2)}</span>
</span>
<span>
<span className="text-muted-foreground">Height:&nbsp;</span>
<span className="text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
<span className="font-mono text-foreground">{field.pageHeight.toFixed(2)}</span>
</span>
</div>
@@ -36,14 +36,14 @@ export const DropdownFieldAdvancedSettings = ({
const { _ } = useLingui();
const [showValidation, setShowValidation] = useState(false);
const [values, setValues] = useState(fieldState.values ?? [{ value: 'Option 1' }]);
const [values, setValues] = useState(fieldState.values ?? [{ value: _(msg`Option 1`) }]);
const [readOnly, setReadOnly] = useState(fieldState.readOnly ?? false);
const [required, setRequired] = useState(fieldState.required ?? false);
const [defaultValue, setDefaultValue] = useState(fieldState.defaultValue ?? 'Option 1');
const [defaultValue, setDefaultValue] = useState(fieldState.defaultValue ?? _(msg`Option 1`));
const addValue = () => {
setValues([...values, { value: 'New option' }]);
handleFieldChange('values', [...values, { value: 'New option' }]);
setValues([...values, { value: _(msg`New option`) }]);
handleFieldChange('values', [...values, { value: _(msg`New option`) }]);
};
const removeValue = (index: number) => {
@@ -90,11 +90,11 @@ export const DropdownFieldAdvancedSettings = ({
}, [values]);
useEffect(() => {
setValues(fieldState.values ?? [{ value: 'Option 1' }]);
setValues(fieldState.values ?? [{ value: _(msg`Option 1`) }]);
}, [fieldState.values]);
useEffect(() => {
setDefaultValue(fieldState.defaultValue ?? 'Option 1');
setDefaultValue(fieldState.defaultValue ?? _(msg`Option 1`));
}, [fieldState.defaultValue]);
return (
@@ -114,7 +114,7 @@ export const DropdownFieldAdvancedSettings = ({
handleFieldChange('defaultValue', val);
}}
>
<SelectTrigger className="text-muted-foreground bg-background mt-2 w-full">
<SelectTrigger className="mt-2 w-full bg-background text-muted-foreground">
<SelectValue defaultValue={defaultValue} placeholder={`-- ${_(msg`Select`)} --`} />
</SelectTrigger>
<SelectContent position="popper">
@@ -152,7 +152,7 @@ export const DropdownFieldAdvancedSettings = ({
</div>
</div>
<Button
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 mt-2 border"
className="mt-2 border border-foreground/10 bg-foreground/10 hover:bg-foreground/5"
variant="outline"
onClick={() => setShowValidation((prev) => !prev)}
>
@@ -183,7 +183,7 @@ export const DropdownFieldAdvancedSettings = ({
</div>
))}
<Button
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 ml-9 mt-4 border"
className="ml-9 mt-4 border border-foreground/10 bg-foreground/10 hover:bg-foreground/5"
variant="outline"
onClick={addValue}
>
@@ -31,7 +31,7 @@ export const RadioFieldAdvancedSettings = ({
const [showValidation, setShowValidation] = useState(false);
const [values, setValues] = useState(
fieldState.values ?? [{ id: 1, checked: false, value: 'Default value' }],
fieldState.values ?? [{ id: 1, checked: false, value: _(msg`Default value`) }],
);
const [readOnly, setReadOnly] = useState(fieldState.readOnly ?? false);
const [required, setRequired] = useState(fieldState.required ?? false);
@@ -99,7 +99,7 @@ export const RadioFieldAdvancedSettings = ({
};
useEffect(() => {
setValues(fieldState.values ?? [{ id: 1, checked: false, value: 'Default value' }]);
setValues(fieldState.values ?? [{ id: 1, checked: false, value: _(msg`Default value`) }]);
}, [fieldState.values]);
useEffect(() => {
@@ -122,7 +122,7 @@ export const RadioFieldAdvancedSettings = ({
</Label>
<Input
id="label"
className="bg-background mt-2"
className="mt-2 bg-background"
placeholder={_(msg`Field label`)}
value={fieldState.label}
onChange={(e) => handleFieldChange('label', e.target.value)}
@@ -150,7 +150,7 @@ export const RadioFieldAdvancedSettings = ({
</div>
</div>
<Button
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 mt-2 border"
className="mt-2 border border-foreground/10 bg-foreground/10 hover:bg-foreground/5"
variant="outline"
onClick={() => setShowValidation((prev) => !prev)}
>
@@ -167,7 +167,7 @@ export const RadioFieldAdvancedSettings = ({
{values.map((value) => (
<div key={value.id} className="mt-2 flex items-center gap-4">
<Checkbox
className="data-[state=checked]:bg-documenso border-foreground/30 data-[state=checked]:ring-primary dark:data-[state=checked]:ring-offset-background h-5 w-5 rounded-full data-[state=checked]:ring-1 data-[state=checked]:ring-offset-2 data-[state=checked]:ring-offset-white"
className="h-5 w-5 rounded-full border-foreground/30 data-[state=checked]:bg-documenso data-[state=checked]:ring-1 data-[state=checked]:ring-primary data-[state=checked]:ring-offset-2 data-[state=checked]:ring-offset-white dark:data-[state=checked]:ring-offset-background"
checked={value.checked}
onCheckedChange={(checked) => handleCheckedChange(Boolean(checked), value.id)}
/>
@@ -186,7 +186,7 @@ export const RadioFieldAdvancedSettings = ({
</div>
))}
<Button
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 ml-9 mt-4 border"
className="ml-9 mt-4 border border-foreground/10 bg-foreground/10 hover:bg-foreground/5"
variant="outline"
onClick={addValue}
>
@@ -4,6 +4,7 @@ import { FieldType } from '@prisma/client';
import { z } from 'zod';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { zEmail } from '@documenso/lib/utils/zod';
export const ZDocumentFlowFormSchema = z.object({
title: z.string().min(1),
@@ -12,7 +13,7 @@ export const ZDocumentFlowFormSchema = z.object({
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z.string().min(1).email(),
email: zEmail().min(1),
name: z.string(),
}),
),
+5 -4
View File
@@ -1,3 +1,4 @@
import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import {
CalendarDays,
@@ -103,10 +104,10 @@ export const FieldSelector = ({
)}
>
<CardContent className="relative flex items-center justify-center gap-x-2 px-6 py-4">
{Icon && <Icon className="text-muted-foreground h-4 w-4" />}
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<span
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-sm',
'text-sm text-muted-foreground group-data-[selected]:text-foreground',
field.type === FieldType.SIGNATURE && 'invisible',
)}
>
@@ -114,8 +115,8 @@ export const FieldSelector = ({
</span>
{field.type === FieldType.SIGNATURE && (
<div className="text-muted-foreground font-signature absolute inset-0 flex items-center justify-center text-lg">
Signature
<div className="absolute inset-0 flex items-center justify-center font-signature text-lg text-muted-foreground">
<Trans>Signature</Trans>
</div>
)}
</CardContent>
@@ -1,10 +0,0 @@
import {
type LoadedPDFDocument,
type OnPDFViewerPageClick,
PDFViewer,
type PDFViewerProps,
} from './base';
export { PDFViewer, type LoadedPDFDocument, type OnPDFViewerPageClick, type PDFViewerProps };
export default PDFViewer;
-290
View File
@@ -1,290 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem } from '@prisma/client';
import { base64 } from '@scure/base';
import { Loader } from 'lucide-react';
import { type PDFDocumentProxy } from 'pdfjs-dist';
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { cn } from '../../lib/utils';
import { useToast } from '../use-toast';
export type LoadedPDFDocument = PDFDocumentProxy;
/**
* This imports the worker from the `pdfjs-dist` package.
* Wrapped in typeof window check to prevent SSR evaluation.
*/
if (typeof window !== 'undefined') {
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
}
const pdfViewerOptions = {
cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps/`,
};
export type OnPDFViewerPageClick = (_event: {
pageNumber: number;
numPages: number;
originalEvent: React.MouseEvent<HTMLDivElement, MouseEvent>;
pageHeight: number;
pageWidth: number;
pageX: number;
pageY: number;
}) => void | Promise<void>;
const PDFLoader = () => (
<>
<Loader className="h-12 w-12 animate-spin text-documenso" />
<p className="mt-4 text-muted-foreground">
<Trans>Loading document...</Trans>
</p>
</>
);
export type PDFViewerProps = {
className?: string;
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined;
presignToken?: string | undefined;
version: 'original' | 'signed';
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
onPageClick?: OnPDFViewerPageClick;
overrideData?: string;
customPageRenderer?: React.FunctionComponent;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
export const PDFViewer = ({
className,
envelopeItem,
token,
presignToken,
version,
onDocumentLoad,
onPageClick,
overrideData,
customPageRenderer,
...props
}: PDFViewerProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const $el = useRef<HTMLDivElement>(null);
const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false);
const [documentBytes, setDocumentBytes] = useState<Uint8Array | null>(
overrideData ? base64.decode(overrideData) : null,
);
const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0);
const [pdfError, setPdfError] = useState(false);
const isLoading = isDocumentBytesLoading || !documentBytes;
const envelopeItemFile = useMemo(() => {
if (!documentBytes) {
return null;
}
return {
data: documentBytes,
};
}, [documentBytes]);
const onDocumentLoaded = (doc: LoadedPDFDocument) => {
setNumPages(doc.numPages);
onDocumentLoad?.(doc);
};
const onDocumentPageClick = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
pageNumber: number,
) => {
const $el = event.target instanceof HTMLElement ? event.target : null;
if (!$el) {
return;
}
const $page = $el.closest(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
return;
}
const { height, width, top, left } = $page.getBoundingClientRect();
const pageX = event.clientX - left;
const pageY = event.clientY - top;
if (onPageClick) {
void onPageClick({
pageNumber,
numPages,
originalEvent: event,
pageHeight: height,
pageWidth: width,
pageX,
pageY,
});
}
};
useEffect(() => {
if ($el.current) {
const $current = $el.current;
const { width } = $current.getBoundingClientRect();
setWidth(width);
const onResize = () => {
const { width } = $current.getBoundingClientRect();
setWidth(width);
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}
}, []);
useEffect(() => {
if (overrideData) {
const bytes = base64.decode(overrideData);
setDocumentBytes(bytes);
return;
}
const fetchDocumentBytes = async () => {
try {
setIsDocumentBytesLoading(true);
const documentUrl = getEnvelopeItemPdfUrl({
type: 'view',
envelopeItem: envelopeItem,
token,
presignToken,
});
const bytes = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
setDocumentBytes(new Uint8Array(bytes));
setIsDocumentBytesLoading(false);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while loading the document.`),
variant: 'destructive',
});
}
};
void fetchDocumentBytes();
}, [envelopeItem.envelopeId, envelopeItem.id, token, version, toast, overrideData]);
return (
<div ref={$el} className={cn('overflow-hidden', className)} {...props}>
{isLoading ? (
<div
className={cn(
'flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden rounded',
)}
>
<PDFLoader />
</div>
) : (
<>
<PDFDocument
file={envelopeItemFile}
className={cn('w-full overflow-hidden rounded', {
'h-[80vh] max-h-[60rem]': numPages === 0,
})}
onLoadSuccess={(d) => onDocumentLoaded(d)}
// Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop.
// Therefore we add some additional custom error handling.
onSourceError={() => {
setPdfError(true);
}}
externalLinkTarget="_blank"
loading={
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
{pdfError ? (
<div className="text-center text-muted-foreground">
<p>
<Trans>Something went wrong while loading the document.</Trans>
</p>
<p className="mt-1 text-sm">
<Trans>Please try again or contact our support.</Trans>
</p>
</div>
) : (
<PDFLoader />
)}
</div>
}
error={
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
<div className="text-center text-muted-foreground">
<p>
<Trans>Something went wrong while loading the document.</Trans>
</p>
<p className="mt-1 text-sm">
<Trans>Please try again or contact our support.</Trans>
</p>
</div>
</div>
}
options={pdfViewerOptions}
>
{Array(numPages)
.fill(null)
.map((_, i) => (
<div key={i} className="last:-mb-2">
<div className="overflow-hidden rounded border border-border will-change-transform">
<PDFPage
pageNumber={i + 1}
width={width}
renderAnnotationLayer={false}
renderTextLayer={false}
loading={() => ''}
renderMode={customPageRenderer ? 'custom' : 'canvas'}
customRenderer={customPageRenderer}
onClick={(e) => onDocumentPageClick(e, i + 1)}
/>
</div>
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
<Trans>
Page {i + 1} of {numPages}
</Trans>
</p>
</div>
))}
</PDFDocument>
</>
)}
</div>
);
};
export default PDFViewer;
@@ -1 +0,0 @@
export * from './base';
@@ -1,7 +0,0 @@
import { ClientOnly } from '../../components/client-only';
import { PDFViewer, type PDFViewerProps } from './base.client';
export const PDFViewerLazy = (props: PDFViewerProps) => {
return <ClientOnly fallback={<div>Loading...</div>}>{() => <PDFViewer {...props} />}</ClientOnly>;
};
+17 -25
View File
@@ -2,12 +2,12 @@ import { useCallback, useMemo, useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { RecipientRole, SendStatus, SigningStatus } 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 type { TRecipientLite } from '@documenso/lib/types/recipient';
import { getRecipientColorStyles } from '../lib/recipient-colors';
import { cn } from '../lib/utils';
@@ -18,9 +18,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
export interface RecipientSelectorProps {
className?: string;
selectedRecipient: Recipient | null;
onSelectedRecipientChange: (recipient: Recipient) => void;
recipients: Recipient[];
selectedRecipient: TRecipientLite | null;
onSelectedRecipientChange: (recipient: TRecipientLite) => void;
recipients: TRecipientLite[];
align?: 'center' | 'end' | 'start';
}
@@ -35,7 +35,7 @@ export const RecipientSelector = ({
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const recipientsByRole = useMemo(() => {
const recipientsWithRole: Record<RecipientRole, Recipient[]> = {
const recipientsWithRole: Record<RecipientRole, TRecipientLite[]> = {
CC: [],
VIEWER: [],
SIGNER: [],
@@ -67,12 +67,12 @@ export const RecipientSelector = ({
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[(r) => r.id, 'asc'],
),
] as [RecipientRole, Recipient[]],
] as [RecipientRole, TRecipientLite[]],
);
}, [recipientsByRole]);
const getRecipientLabel = useCallback(
(recipient: Recipient) => {
(recipient: TRecipientLite) => {
if (recipient.name && recipient.email) {
return `${recipient.name} (${recipient.email})`;
}
@@ -101,13 +101,9 @@ export const RecipientSelector = ({
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,
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
getRecipientColorStyles(recipients.findIndex((r) => r.id === selectedRecipient?.id))
.comboBoxTrigger,
className,
)}
>
@@ -126,21 +122,21 @@ export const RecipientSelector = ({
<CommandInput />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
<span className="inline-block px-4 text-muted-foreground">
<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">
<div className="mb-1 ml-2 mt-2 text-xs font-medium text-muted-foreground">
{_(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"
className="px-4 pb-4 pt-2.5 text-center text-xs text-muted-foreground/80"
>
<Trans>No recipients with this role</Trans>
</div>
@@ -151,12 +147,8 @@ export const RecipientSelector = ({
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,
getRecipientColorStyles(recipients.findIndex((r) => r.id === recipient.id))
.comboBoxItem,
{
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
},
@@ -168,7 +160,7 @@ export const RecipientSelector = ({
disabled={recipient.signingStatus !== SigningStatus.NOT_SIGNED}
>
<span
className={cn('text-foreground/70 truncate', {
className={cn('truncate text-foreground/70', {
'text-foreground/80': recipient.id === selectedRecipient?.id,
})}
>
@@ -190,7 +182,7 @@ export const RecipientSelector = ({
<Info className="ml-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
This document has already been sent to this recipient. You can no longer
edit this recipient.
+5 -5
View File
@@ -23,7 +23,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'border-input ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
disabled={loading || props.disabled}
@@ -56,7 +56,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
'bg-popover text-popover-foreground animate-in fade-in-80 relative z-[1001] min-w-[8rem] overflow-hidden rounded-md border shadow-md',
'relative z-[1001] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-80',
position === 'popper' && 'translate-y-1',
className,
)}
@@ -65,7 +65,7 @@ const SelectContent = React.forwardRef<
>
<SelectPrimitive.Viewport
className={cn(
'p-1',
'max-h-60 overflow-y-auto p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
@@ -98,7 +98,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
@@ -121,7 +121,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('bg-muted -mx-1 my-1 h-px', className)}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
@@ -4,7 +4,7 @@ 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 { Field, Recipient } from '@prisma/client';
import type { Field } from '@prisma/client';
import { FieldType, RecipientRole, SendStatus } from '@prisma/client';
import {
CalendarDays,
@@ -24,13 +24,14 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import {
type TFieldMetaSchema as FieldMeta,
ZFieldMetaSchema,
} from '@documenso/lib/types/field-meta';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
@@ -57,7 +58,7 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { getRecipientColorStyles, useRecipientColors } from '../../lib/recipient-colors';
import { getRecipientColorStyles } from '../../lib/recipient-colors';
import type { FieldFormType } from '../document-flow/add-fields';
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
import { Form } from '../form/form';
@@ -75,7 +76,7 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type AddTemplateFieldsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
recipients: TRecipientLite[];
fields: Field[];
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
onAutoSave: (_data: TAddTemplateFieldsFormSchema) => Promise<void>;
@@ -154,13 +155,11 @@ export const AddTemplateFieldsFormPartial = ({
});
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
const [selectedSigner, setSelectedSigner] = useState<TRecipientLite | null>(null);
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
const selectedSignerStyles = useRecipientColors(
selectedSignerIndex === -1 ? 0 : selectedSignerIndex,
);
const selectedSignerStyles = getRecipientColorStyles(selectedSignerIndex);
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
@@ -188,13 +187,15 @@ export const AddTemplateFieldsFormPartial = ({
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
const totalPages = getPdfPagesCount();
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (totalPages < 1) {
return;
}
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
if (pageNumber === lastActiveField.pageNumber) {
return;
continue;
}
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
@@ -208,7 +209,7 @@ export const AddTemplateFieldsFormPartial = ({
};
append(newField);
});
}
void handleAutoSave();
return;
@@ -491,7 +492,7 @@ export const AddTemplateFieldsFormPartial = ({
}, [recipients]);
const recipientsByRole = useMemo(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
const recipientsByRole: Record<RecipientRole, TRecipientLite[]> = {
CC: [],
VIEWER: [],
SIGNER: [],
@@ -520,7 +521,7 @@ export const AddTemplateFieldsFormPartial = ({
const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter(
return (Object.entries(recipientsByRole) as [RecipientRole, TRecipientLite[]][]).filter(
([role]) =>
role !== RecipientRole.CC &&
role !== RecipientRole.VIEWER &&
@@ -651,7 +652,7 @@ export const AddTemplateFieldsFormPartial = ({
role="combobox"
className={cn(
'mb-12 mt-2 justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
selectedSignerStyles?.comboxBoxTrigger,
selectedSignerStyles?.comboBoxTrigger,
)}
>
{selectedSigner?.email &&
@@ -708,11 +709,8 @@ export const AddTemplateFieldsFormPartial = ({
className={cn(
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === recipient.id),
0,
),
)?.comboxBoxItem,
recipients.findIndex((r) => r.id === recipient.id),
)?.comboBoxItem,
)}
onSelect={() => {
setSelectedSigner(recipient);
@@ -797,7 +795,7 @@ export const AddTemplateFieldsFormPartial = ({
)}
>
<Contact className="h-4 w-4" />
Initials
<Trans>Initials</Trans>
</p>
</CardContent>
</Card>
@@ -953,7 +951,7 @@ export const AddTemplateFieldsFormPartial = ({
)}
>
<Disc className="h-4 w-4" />
Radio
<Trans>Radio</Trans>
</p>
</CardContent>
</Card>
@@ -979,8 +977,7 @@ export const AddTemplateFieldsFormPartial = ({
)}
>
<CheckSquare className="h-4 w-4" />
{/* Not translated on purpose. */}
Checkbox
<Trans>Checkbox</Trans>
</p>
</CardContent>
</Card>
@@ -7,7 +7,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TemplateDirectLink } from '@prisma/client';
import { DocumentSigningOrder, type Field, type Recipient, RecipientRole } from '@prisma/client';
import { DocumentSigningOrder, type Field, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
@@ -17,6 +17,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
@@ -49,12 +50,12 @@ import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
type AutoSaveResponse = {
recipients: Recipient[];
recipients: TRecipientLite[];
};
export type AddTemplatePlaceholderRecipientsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
recipients: TRecipientLite[];
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
@@ -533,7 +534,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircle className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
@@ -586,7 +587,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'bg-widget-foreground pointer-events-none rounded-md pt-2':
'pointer-events-none rounded-md bg-widget-foreground pt-2':
snapshot.isDragging,
})}
>
@@ -754,7 +755,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
handleRoleChange(index, value as RecipientRole);
}}
disabled={isSubmitting}
hideCCRecipients={isSignerDirectRecipient(signer)}
hideCCerRole={isSignerDirectRecipient(signer)}
/>
</FormControl>
@@ -768,11 +769,11 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<TooltipTrigger className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80">
<Link2Icon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<h3 className="text-foreground text-lg font-semibold">
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<h3 className="text-lg font-semibold text-foreground">
<Trans>Direct link receiver</Trans>
</h3>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
<Trans>
This field cannot be modified or deleted. When you share
this template's direct link or add it to your public
@@ -829,7 +830,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
className="bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary"
disabled={
isSubmitting ||
@@ -852,7 +853,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
/>
<label
className="text-muted-foreground ml-2 text-sm"
className="ml-2 text-sm text-muted-foreground"
htmlFor="showAdvancedRecipientSettings"
>
<Trans>Show advanced settings</Trans>
@@ -2,6 +2,7 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { zEmail } from '@documenso/lib/utils/zod';
export const ZAddTemplatePlacholderRecipientsFormSchema = z
.object({
@@ -9,7 +10,7 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z.string().min(1).email(),
email: zEmail().min(1),
name: z.string().min(1, { message: 'Name is required' }),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -2,8 +2,8 @@ import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentDistributionMethod, type Field, type Recipient } from '@prisma/client';
import { DocumentVisibility, TeamMemberRole, TemplateType } from '@prisma/client';
import { DocumentDistributionMethod, type Field } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
@@ -19,6 +19,7 @@ import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import type { TTemplate } from '@documenso/lib/types/template';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
@@ -36,6 +37,10 @@ import {
DocumentVisibilitySelect,
DocumentVisibilityTooltip,
} from '@documenso/ui/components/document/document-visibility-select';
import {
TemplateTypeSelect,
TemplateTypeTooltip,
} from '@documenso/ui/components/template/template-type-select';
import {
Accordion,
AccordionContent,
@@ -77,7 +82,7 @@ import { ZAddTemplateSettingsFormSchema } from './add-template-settings.types';
export type AddTemplateSettingsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
recipients: TRecipientLite[];
fields: Field[];
isDocumentPdfLoaded: boolean;
template: TTemplate;
@@ -96,7 +101,7 @@ export const AddTemplateSettingsFormPartial = ({
onSubmit,
onAutoSave,
}: AddTemplateSettingsFormProps) => {
const { t, i18n } = useLingui();
const { t } = useLingui();
const organisation = useCurrentOrganisation();
@@ -109,6 +114,7 @@ export const AddTemplateSettingsFormPartial = ({
defaultValues: {
title: template.title,
externalId: template.externalId || undefined,
templateType: template.type || TemplateType.PRIVATE,
visibility: template.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || [],
@@ -262,7 +268,7 @@ export const AddTemplateSettingsFormPartial = ({
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
{t(language.full)}
</SelectItem>
))}
</SelectContent>
@@ -325,6 +331,29 @@ export const AddTemplateSettingsFormPartial = ({
/>
)}
<FormField
control={form.control}
name="templateType"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Template type</Trans>
<TemplateTypeTooltip organisationTeamCount={organisation.teams.length} />
</FormLabel>
<FormControl>
<TemplateTypeSelect
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.distributionMethod"
@@ -391,7 +420,7 @@ export const AddTemplateSettingsFormPartial = ({
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{i18n._(description)}
{t(description)}
</SelectItem>
),
)}
@@ -1,6 +1,6 @@
import { msg } from '@lingui/core/macro';
import { DocumentDistributionMethod } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DocumentVisibility, TemplateType } from '@prisma/client';
import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
@@ -17,10 +17,12 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { zEmail } from '@documenso/lib/utils/zod';
export const ZAddTemplateSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
externalId: z.string().optional(),
templateType: z.nativeEnum(TemplateType).optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: z
.array(z.union([ZDocumentAccessAuthTypesSchema, z.literal('-1')]))
@@ -49,10 +51,7 @@ export const ZAddTemplateSettingsFormSchema = z.object({
.optional()
.default('en'),
emailId: z.string().nullable(),
emailReplyTo: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().email().optional(),
),
emailReplyTo: z.preprocess((val) => (val === '' ? undefined : val), zEmail().optional()),
emailSettings: ZDocumentEmailSettingsSchema,
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
+8 -1
View File
@@ -8,7 +8,14 @@ const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipTrigger = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
>(({ type = 'button', ...props }, ref) => (
<TooltipPrimitive.Trigger ref={ref} type={type} {...props} />
));
TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
+13
View File
@@ -137,6 +137,8 @@
/* Surface */
--new-surface-black: 0, 0%, 0%;
--new-surface-white: 0, 0%, 91%;
--envelope-editor-background: 210 40% 98.04%;
}
.dark:not(.dark-mode-disabled) {
@@ -181,6 +183,8 @@
--warning: 54 96% 45%;
--gold: 47.9 95.8% 53.1%;
--envelope-editor-background: 0 0% 14.9%;
}
}
@@ -230,6 +234,15 @@
scrollbar-gutter: stable;
}
.scrollbar-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hidden::-webkit-scrollbar {
display: none;
}
/* .custom-scrollbar::-webkit-scrollbar-track {
border-radius: 10px;
}
+3 -1
View File
@@ -1,12 +1,14 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const baseConfig = require('@documenso/tailwind-config');
const { RECIPIENT_DYNAMIC_CLASS } = require('./lib/recipient-colors');
module.exports = {
...baseConfig,
presets: [baseConfig],
content: [
...baseConfig.content,
'./primitives/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./lib/**/*.{ts,tsx}',
],
safelist: [RECIPIENT_DYNAMIC_CLASS],
};