feat: add envelope editor

This commit is contained in:
David Nguyen
2025-10-12 23:35:54 +11:00
parent bf89bc781b
commit 0da8e7dbc6
307 changed files with 24657 additions and 3681 deletions

View File

@ -51,6 +51,7 @@ export function FieldToolTip({ children, color, className = '', field }: FieldTo
return createPortal(
<div
id="field-tooltip"
className={cn('pointer-events-none absolute')}
style={{
top: `${coords.y}px`,

View File

@ -0,0 +1,23 @@
import React, { Suspense, lazy } from 'react';
import { type PDFDocumentProxy } from 'pdfjs-dist';
export type LoadedPDFDocument = PDFDocumentProxy;
export type PDFViewerProps = {
className?: string;
onDocumentLoad?: () => void;
[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 client component...</div>}>
<EnvelopePdfViewer {...props} />
</Suspense>
);
};
export default PDFViewerKonvaLazy;

View File

@ -0,0 +1,173 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans } 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 { cn } from '@documenso/ui/lib/utils';
export type LoadedPDFDocument = PDFDocumentProxy;
/**
* This imports the worker from the `pdfjs-dist` package.
*/
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.js',
import.meta.url,
).toString();
const PDFLoader = () => (
<>
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">
<Trans>Loading document...</Trans>
</p>
</>
);
export type PdfViewerKonvaProps = {
className?: string;
onDocumentLoad?: () => void;
customPageRenderer?: React.FunctionComponent;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
export const PdfViewerKonva = ({
className,
onDocumentLoad,
customPageRenderer,
...props
}: PdfViewerKonvaProps) => {
const $el = useRef<HTMLDivElement>(null);
const { getPdfBuffer, currentEnvelopeItem } = useCurrentEnvelopeRender();
const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0);
const [pdfError, setPdfError] = useState(false);
const envelopeItemFile = useMemo(() => {
const data = getPdfBuffer(currentEnvelopeItem?.documentDataId || '');
if (!data || data.status !== 'loaded') {
return null;
}
return {
data: new Uint8Array(data.file),
};
}, [currentEnvelopeItem?.documentDataId, 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-[800px] overflow-hidden', className)} {...props}>
{envelopeItemFile && Konva ? (
<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="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
{pdfError ? (
<div className="text-muted-foreground text-center">
<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="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
<div className="text-muted-foreground text-center">
<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>
}
>
{Array(numPages)
.fill(null)
.map((_, i) => (
<div key={i} className="last:-mb-2">
<div className="border-border overflow-hidden rounded border will-change-transform">
<PDFPage
pageNumber={i + 1}
width={width}
renderAnnotationLayer={false}
renderTextLayer={false}
loading={() => ''}
renderMode={customPageRenderer ? 'custom' : 'canvas'}
customRenderer={customPageRenderer}
/>
</div>
<p className="text-muted-foreground/80 my-2 text-center text-[11px]">
<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;

View File

@ -10,7 +10,7 @@ import { Card, CardContent } from '../primitives/card';
export type SigningCardProps = {
className?: string;
name: string;
signature?: Signature;
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'>;
signingCelebrationImage?: string;
};
@ -154,7 +154,7 @@ export const SigningCard3D = ({
type SigningCardContentProps = {
name: string;
signature?: Signature;
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'>;
className?: string;
};

View File

@ -7,16 +7,22 @@ export type RecipientColorMap = Record<number, RecipientColorStyles>;
export type RecipientColorStyles = {
base: string;
baseRing: string;
baseRingHover: string;
fieldItem: string;
fieldItemInitials: string;
comboxBoxTrigger: string;
comboxBoxItem: 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: {
base: 'ring-neutral-400',
baseRing: 'rgba(176, 176, 176, 1)',
baseRingHover: 'rgba(176, 176, 176, 1)',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: '',
comboxBoxTrigger:
@ -26,6 +32,8 @@ export const RECIPIENT_COLOR_STYLES = {
green: {
base: 'ring-recipient-green hover:bg-recipient-green/30',
baseRing: 'rgba(122, 195, 85, 1)',
baseRingHover: 'rgba(122, 195, 85, 0.3)',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-green',
comboxBoxTrigger:
@ -35,6 +43,8 @@ export const RECIPIENT_COLOR_STYLES = {
blue: {
base: 'ring-recipient-blue hover:bg-recipient-blue/30',
baseRing: 'rgba(56, 123, 199, 1)',
baseRingHover: 'rgba(56, 123, 199, 0.3)',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-blue',
comboxBoxTrigger:
@ -44,6 +54,8 @@ export const RECIPIENT_COLOR_STYLES = {
purple: {
base: 'ring-recipient-purple hover:bg-recipient-purple/30',
baseRing: 'rgba(151, 71, 255, 1)',
baseRingHover: 'rgba(151, 71, 255, 0.3)',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-purple',
comboxBoxTrigger:
@ -53,6 +65,8 @@ export const RECIPIENT_COLOR_STYLES = {
orange: {
base: 'ring-recipient-orange hover:bg-recipient-orange/30',
baseRing: 'rgba(246, 159, 30, 1)',
baseRingHover: 'rgba(246, 159, 30, 0.3)',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-orange',
comboxBoxTrigger:
@ -62,6 +76,8 @@ export const RECIPIENT_COLOR_STYLES = {
yellow: {
base: 'ring-recipient-yellow hover:bg-recipient-yellow/30',
baseRing: 'rgba(219, 186, 0, 1)',
baseRingHover: 'rgba(219, 186, 0, 0.3)',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-yellow',
comboxBoxTrigger:
@ -71,6 +87,8 @@ export const RECIPIENT_COLOR_STYLES = {
pink: {
base: 'ring-recipient-pink hover:bg-recipient-pink/30',
baseRing: 'rgba(217, 74, 186, 1)',
baseRingHover: 'rgba(217, 74, 186, 0.3)',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-pink',
comboxBoxTrigger:
@ -79,7 +97,7 @@ export const RECIPIENT_COLOR_STYLES = {
},
} satisfies Record<string, RecipientColorStyles>;
export type CombinedStylesKey = keyof typeof RECIPIENT_COLOR_STYLES;
export type TRecipientColor = keyof typeof RECIPIENT_COLOR_STYLES;
export const AVAILABLE_RECIPIENT_COLORS = [
'green',
@ -88,7 +106,7 @@ export const AVAILABLE_RECIPIENT_COLORS = [
'orange',
'yellow',
'pink',
] satisfies CombinedStylesKey[];
] satisfies TRecipientColor[];
export const useRecipientColors = (index: number) => {
const key = AVAILABLE_RECIPIENT_COLORS[index % AVAILABLE_RECIPIENT_COLORS.length];

View File

@ -1,27 +1,25 @@
import * as React from 'react';
import { motion, useMotionTemplate, useMotionValue } from 'framer-motion';
import { cn } from '../lib/utils';
export type CardProps = React.HTMLAttributes<HTMLDivElement> & {
spotlight?: boolean;
gradient?: boolean;
degrees?: number;
/**
* Not sure if this is needed, but added a toggle so it defaults to true since that was how it was before.
*
* This is required to be set false if you want drag drop to work within this element.
*/
backdropBlur?: boolean;
};
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, children, gradient = false, spotlight = false, degrees = 120, ...props }, ref) => {
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
const handleMouseMove = ({ currentTarget, clientX, clientY }: React.MouseEvent) => {
const { left, top } = currentTarget.getBoundingClientRect();
mouseX.set(clientX - left);
mouseY.set(clientY - top);
};
(
{ className, children, gradient = false, degrees = 120, backdropBlur = true, ...props },
ref,
) => {
return (
<div
ref={ref}
@ -32,8 +30,9 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
} as React.CSSProperties
}
className={cn(
'bg-background text-foreground group relative rounded-lg border-2 backdrop-blur-[2px]',
'bg-background text-foreground group relative rounded-lg border-2',
{
'backdrop-blur-[2px]': backdropBlur,
'gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.primary.DEFAULT/50%)_5%,theme(colors.border/80%)_30%)]':
gradient,
'dark:gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.primary.DEFAULT/70%)_5%,theme(colors.border/80%)_30%)]':
@ -44,23 +43,8 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
},
className,
)}
onMouseMove={handleMouseMove}
{...props}
>
{spotlight && (
<motion.div
className="pointer-events-none absolute -inset-[2px] rounded-lg opacity-0 transition duration-300 group-hover:opacity-100"
style={{
background: useMotionTemplate`
radial-gradient(
300px circle at ${mouseX}px ${mouseY}px,
hsl(var(--primary) / 7%),
transparent 80%
)
`,
}}
/>
)}
{children}
</div>
);

View File

@ -26,9 +26,11 @@ import { Card, CardContent } from './card';
export type DocumentDropzoneProps = {
className?: string;
allowMultiple?: boolean;
disabled?: boolean;
disabledHeading?: MessageDescriptor;
disabledMessage?: MessageDescriptor;
onDrop?: (_file: File) => void | Promise<void>;
onDrop?: (_file: File[]) => void | Promise<void>;
onDropRejected?: () => void | Promise<void>;
type?: 'document' | 'template';
[key: string]: unknown;
@ -36,9 +38,11 @@ export type DocumentDropzoneProps = {
export const DocumentDropzone = ({
className,
allowMultiple,
onDrop,
onDropRejected,
disabled,
disabledHeading,
disabledMessage = msg`You cannot upload documents at this time.`,
type = 'document',
...props
@ -51,11 +55,11 @@ export const DocumentDropzone = ({
accept: {
'application/pdf': ['.pdf'],
},
multiple: false,
multiple: allowMultiple,
disabled,
onDrop: ([acceptedFile]) => {
if (acceptedFile && onDrop) {
void onDrop(acceptedFile);
onDrop: (acceptedFiles) => {
if (acceptedFiles.length > 0 && onDrop) {
void onDrop(acceptedFiles);
}
},
onDropRejected: () => {
@ -67,7 +71,9 @@ export const DocumentDropzone = ({
});
const heading = {
document: disabled ? msg`You have reached your document limit.` : msg`Add a document`,
document: disabled
? disabledHeading || msg`You have reached your document limit.`
: msg`Add a document`,
template: msg`Upload Template Document`,
};
@ -153,7 +159,7 @@ export const DocumentDropzone = ({
<input {...getInputProps()} />
<p className="text-foreground mt-8 font-medium">{_(heading[type])}</p>
<p className="text-foreground mt-6 font-medium">{_(heading[type])}</p>
<p className="text-muted-foreground/80 mt-1 text-center text-sm">
{_(disabled ? disabledMessage : msg`Drag & drop your PDF here.`)}

View File

@ -19,7 +19,6 @@ import {
} from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { prop, sortBy } from 'remeda';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
@ -551,29 +550,6 @@ export const AddFieldsFormPartial = ({
return recipientsByRole;
}, [recipients]);
const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][])
.filter(
([role]) =>
role !== RecipientRole.CC &&
role !== RecipientRole.VIEWER &&
role !== RecipientRole.ASSISTANT,
)
.map(
([role, roleRecipients]) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
[
role,
sortBy(
roleRecipients,
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[prop('id'), 'asc'],
),
] as [RecipientRole, Recipient[]],
);
}, [recipientsByRole]);
const handleAdvancedSettings = () => {
setShowAdvancedSettings((prev) => !prev);
};

View File

@ -10,11 +10,11 @@ import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema';
} from '@documenso/lib/types/document-meta';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
export const ZAddSettingsFormSchema = z.object({
title: z

View File

@ -2,14 +2,17 @@ export const numberFormatValues = [
{
label: '123,456,789.00',
value: '123,456,789.00',
regex: /^(?:\d{1,3}(?:,\d{3})*|\d+)(?:\.\d{1,2})?$/,
},
{
label: '123.456.789,00',
value: '123.456.789,00',
regex: /^(?:\d{1,3}(?:\.\d{3})*|\d+)(?:,\d{1,2})?$/,
},
{
label: '123456,789.00',
value: '123456,789.00',
regex: /^(?:\d+)(?:,\d{1,3}(?:\.\d{1,2})?)?$/,
},
];

View File

@ -102,7 +102,7 @@ export const NumberFieldAdvancedSettings = ({
<Trans>Number format</Trans>
</Label>
<Select
value={fieldState.numberFormat}
value={fieldState.numberFormat ?? ''}
onValueChange={(val) => handleInput('numberFormat', val)}
>
<SelectTrigger className="text-muted-foreground bg-background mt-2 w-full">
@ -199,7 +199,7 @@ export const NumberFieldAdvancedSettings = ({
id="minValue"
className="bg-background mt-2"
placeholder="E.g. 0"
value={fieldState.minValue}
value={fieldState.minValue ?? ''}
onChange={(e) => handleInput('minValue', e.target.value)}
/>
</div>
@ -211,7 +211,7 @@ export const NumberFieldAdvancedSettings = ({
id="maxValue"
className="bg-background mt-2"
placeholder="E.g. 100"
value={fieldState.maxValue}
value={fieldState.maxValue ?? ''}
onChange={(e) => handleInput('maxValue', e.target.value)}
/>
</div>

View File

@ -20,9 +20,9 @@ export type DocumentDropzoneProps = {
disabled?: boolean;
loading?: boolean;
disabledMessage?: MessageDescriptor;
onDrop?: (_file: File) => void | Promise<void>;
onDrop?: (_files: File[]) => void | Promise<void>;
onDropRejected?: () => void | Promise<void>;
type?: 'document' | 'template';
type?: 'document' | 'template' | 'envelope';
[key: string]: unknown;
};
@ -48,11 +48,12 @@ export const DocumentDropzone = ({
accept: {
'application/pdf': ['.pdf'],
},
multiple: false,
multiple: type === 'envelope',
disabled,
onDrop: ([acceptedFile]) => {
if (acceptedFile && onDrop) {
void onDrop(acceptedFile);
maxFiles: 10, // Todo: Envelopes - Use claims. And also update other places where this is used.
onDrop: (acceptedFiles) => {
if (acceptedFiles.length > 0 && onDrop) {
void onDrop(acceptedFiles);
}
},
onDropRejected: () => {
@ -66,6 +67,7 @@ export const DocumentDropzone = ({
const heading = {
document: msg`Upload Document`,
template: msg`Upload Template Document`,
envelope: msg`Envelope (beta)`,
};
if (disabled && IS_BILLING_ENABLED()) {

View File

@ -48,7 +48,7 @@ const PDFLoader = () => (
export type PDFViewerProps = {
className?: string;
documentData: DocumentData;
documentData: Pick<DocumentData, 'type' | 'data'>;
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
onPageClick?: OnPDFViewerPageClick;
[key: string]: unknown;

View File

@ -21,6 +21,7 @@ export interface RecipientSelectorProps {
selectedRecipient: Recipient | null;
onSelectedRecipientChange: (recipient: Recipient) => void;
recipients: Recipient[];
align?: 'center' | 'end' | 'start';
}
export const RecipientSelector = ({
@ -28,6 +29,7 @@ export const RecipientSelector = ({
selectedRecipient,
onSelectedRecipientChange,
recipients,
align = 'start',
}: RecipientSelectorProps) => {
const { _ } = useLingui();
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
@ -83,7 +85,7 @@ export const RecipientSelector = ({
recipients.findIndex((r) => r.id === selectedRecipient?.id),
0,
),
).base,
).comboxBoxTrigger,
className,
)}
>
@ -101,8 +103,8 @@ export const RecipientSelector = ({
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command value={selectedRecipient?.email}>
<PopoverContent className="p-0" align={align}>
<Command value={selectedRecipient ? selectedRecipient.id.toString() : undefined}>
<CommandInput />
<CommandEmpty>
@ -149,7 +151,7 @@ export const RecipientSelector = ({
>
<span
className={cn('text-foreground/70 truncate', {
'text-foreground/80': recipient === selectedRecipient,
'text-foreground/80': recipient.id === selectedRecipient?.id,
})}
>
{recipient.name && (
@ -164,10 +166,10 @@ export const RecipientSelector = ({
<div className="ml-auto flex items-center justify-center">
{recipient.sendStatus !== SendStatus.SENT ? (
<Check
aria-hidden={recipient !== selectedRecipient}
aria-hidden={recipient.id !== selectedRecipient?.id}
className={cn('h-4 w-4 flex-shrink-0', {
'opacity-0': recipient !== selectedRecipient,
'opacity-100': recipient === selectedRecipient,
'opacity-0': recipient.id !== selectedRecipient?.id,
'opacity-100': recipient.id === selectedRecipient?.id,
})}
/>
) : (

View File

@ -19,11 +19,11 @@ import {
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 { TTemplate } from '@documenso/lib/types/template';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TDocumentMetaDateFormat } from '@documenso/trpc/server/document-router/schema';
import {
DocumentGlobalAuthAccessSelect,
DocumentGlobalAuthAccessTooltip,
@ -216,7 +216,12 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} maxLength={255} onBlur={handleAutoSave} />
<Input
className="bg-background"
{...field}
maxLength={255}
onBlur={handleAutoSave}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -624,7 +629,12 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} maxLength={255} onBlur={handleAutoSave} />
<Input
className="bg-background"
{...field}
maxLength={255}
onBlur={handleAutoSave}
/>
</FormControl>
<FormMessage />
@ -715,7 +725,12 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} maxLength={255} onBlur={handleAutoSave} />
<Input
className="bg-background"
{...field}
maxLength={255}
onBlur={handleAutoSave}
/>
</FormControl>
<FormMessage />

View File

@ -12,11 +12,11 @@ import {
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema';
} from '@documenso/lib/types/document-meta';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
export const ZAddTemplateSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),