chore: merge main, resolve biome formatting conflicts

Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
This commit is contained in:
ephraimduncan
2026-05-12 11:46:11 +00:00
parent 9194884fbe
commit 138d663c25
1959 changed files with 93488 additions and 47038 deletions
@@ -0,0 +1,40 @@
import { cn } from '@documenso/ui/lib/utils';
import type { ReactNode } from 'react';
export type DetailsCardProps = {
label: ReactNode;
action?: ReactNode;
children: ReactNode;
};
export const DetailsCard = ({ label, action, children }: DetailsCardProps) => {
return (
<div className="rounded-md border bg-muted/30 px-3 py-2">
<div className="flex min-h-9 items-center justify-between gap-3">
<span className="text-muted-foreground">{label}</span>
{action ?? null}
</div>
<div className="mt-2 min-h-9">{children}</div>
</div>
);
};
export type DetailsValueProps = {
children: ReactNode;
isMono?: boolean;
isSelectable?: boolean;
};
export const DetailsValue = ({ children, isMono = true, isSelectable = false }: DetailsValueProps) => {
return (
<div
className={cn(
'flex min-h-10 items-center break-all rounded-md bg-muted px-3 py-2 text-muted-foreground text-xs',
isMono && 'font-mono',
isSelectable && 'select-all',
)}
>
{children}
</div>
);
};
@@ -0,0 +1,152 @@
import { DOCUMENT_VISIBILITY } from '@documenso/lib/constants/document-visibility';
import { type TDocumentEmailSettings, ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
const EMAIL_SETTINGS_LABELS: Record<keyof TDocumentEmailSettings, MessageDescriptor> = {
recipientSigningRequest: msg`Recipient signing request`,
recipientRemoved: msg`Recipient removed`,
recipientSigned: msg`Recipient signed`,
documentPending: msg`Document pending`,
documentCompleted: msg`Document completed`,
documentDeleted: msg`Document deleted`,
ownerDocumentCompleted: msg`Owner document completed`,
ownerRecipientExpired: msg`Owner recipient expired`,
ownerDocumentCreated: msg`Owner document created`,
};
const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocumentEmailSettings)[];
type AdminGlobalSettingsSectionProps = {
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
isTeam?: boolean;
};
export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => {
const { _ } = useLingui();
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
if (!settings) {
return null;
}
const textValue = (value: string | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
}
return value;
};
const brandingTextValue = (value: string | null | undefined) => {
if (value === null || value === undefined || value.trim() === '') {
return notSetLabel;
}
return value;
};
const booleanValue = (value: boolean | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
}
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>;
};
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
return (
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<DetailsCard label={<Trans>Document visibility</Trans>}>
<DetailsValue>
{settings.documentVisibility != null
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
: notSetLabel}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document language</Trans>}>
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document timezone</Trans>}>
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Date format</Trans>}>
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include sender details</Trans>}>
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include audit log</Trans>}>
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Upload signature</Trans>}>
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Draw signature</Trans>}>
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding logo</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding URL</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding company details</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Email reply-to</Trans>}>
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
</DetailsCard>
{isTeam && parsedEmailSettings.success && (
<DetailsCard label={<Trans>Email document settings</Trans>}>
<div className="mt-1 space-y-1 pr-3 pb-2 text-xs">
{emailSettingsKeys.map((key) => (
<div key={key} className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">{_(EMAIL_SETTINGS_LABELS[key])}</span>
<span>{parsedEmailSettings.data[key] ? <Trans>On</Trans> : <Trans>Off</Trans>}</span>
</div>
))}
</div>
</DetailsCard>
)}
<DetailsCard label={<Trans>AI features</Trans>}>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
</DetailsCard>
</div>
);
};
@@ -1,29 +1,26 @@
import type { TCachedLicense } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import {
ArrowRightIcon,
CheckCircle2Icon,
EyeIcon,
EyeOffIcon,
KeyRoundIcon,
Loader2Icon,
RefreshCwIcon,
XCircleIcon,
} from 'lucide-react';
import { DateTime } from 'luxon';
import { useState } from 'react';
import { Link, useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { CardMetric } from './metric-card';
type AdminLicenseCardProps = {
@@ -32,31 +29,33 @@ type AdminLicenseCardProps = {
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
const { t, i18n } = useLingui();
const [isLicenseKeyVisible, setIsLicenseKeyVisible] = useState(false);
const { license } = licenseData || {};
if (!license) {
return (
<div className="relative">
<div className="absolute right-3 top-3 z-10">
<div className="absolute top-3 right-3 z-10">
<AdminLicenseResyncButton />
</div>
<CardMetric icon={KeyRoundIcon} title={t`License`} className="h-fit max-h-fit">
<div className="mt-1 flex items-center justify-center gap-2">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 bg-muted/50">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-muted-foreground/30 border-dashed bg-muted/50">
<KeyRoundIcon className="h-5 w-5 text-muted-foreground/50" />
</div>
<div className="flex flex-col gap-0.5">
{licenseData?.requestedLicenseKey ? (
<>
<p className="text-sm font-medium text-destructive">
<p className="font-medium text-destructive text-sm">
<Trans>Invalid License Key</Trans>
</p>
<p className="text-xs text-muted-foreground">{licenseData.requestedLicenseKey}</p>
{/* Don't need to hide invalid license keys. */}
<p className="text-muted-foreground text-xs">{licenseData.requestedLicenseKey}</p>
</>
) : (
<p className="text-sm font-medium text-muted-foreground">
<p className="font-medium text-muted-foreground text-sm">
<Trans>No License Configured</Trans>
</p>
)}
@@ -64,7 +63,7 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
<Link
to="https://docs.documenso.com/users/licenses/enterprise-edition"
target="_blank"
className="flex flex-row items-center text-xs text-muted-foreground hover:text-muted-foreground/80"
className="flex flex-row items-center text-muted-foreground text-xs hover:text-muted-foreground/80"
>
<Trans>Learn more</Trans> <ArrowRightIcon className="h-3 w-3" />
</Link>
@@ -78,8 +77,8 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
const enabledFlags = Object.entries(license.flags).filter(([, enabled]) => enabled);
return (
<div className="relative max-w-full overflow-hidden rounded-lg border border-border bg-background px-4 pb-6 pt-4 shadow shadow-transparent duration-200 hover:shadow-border/80">
<div className="absolute right-3 top-3">
<div className="relative max-w-full overflow-hidden rounded-lg border border-border bg-background px-4 pt-4 pb-6 shadow shadow-transparent duration-200 hover:shadow-border/80">
<div className="absolute top-3 right-3">
<AdminLicenseResyncButton />
</div>
@@ -88,7 +87,7 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
</div>
<h3 className="text-primary-forground mb-2 flex items-end text-sm font-medium leading-tight">
<h3 className="mb-2 flex items-end font-medium text-primary-forground text-sm leading-tight">
<Trans>Documenso License</Trans>
</h3>
@@ -116,33 +115,46 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-foreground">
<p className="font-medium text-foreground text-sm">
<Trans>License</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{license.name}</p>
<p className="mt-0.5 text-muted-foreground text-xs">{license.name}</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<p className="font-medium text-foreground text-sm">
<Trans>Expires</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{i18n.date(license.periodEnd, DateTime.DATE_MED)}
</p>
<p className="mt-0.5 text-muted-foreground text-xs">{i18n.date(license.periodEnd, DateTime.DATE_MED)}</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<p className="font-medium text-foreground text-sm">
<Trans>License Key</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{license.licenseKey}</p>
<div className="mt-0.5 flex items-center gap-1">
<p className="min-w-0 break-all text-muted-foreground text-xs">
{isLicenseKeyVisible ? license.licenseKey : '•'.repeat(license.licenseKey.length)}
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground"
aria-label={isLicenseKeyVisible ? t`Hide license key` : t`Show license key`}
onClick={() => setIsLicenseKeyVisible((prevState) => !prevState)}
>
{isLicenseKeyVisible ? <EyeOffIcon className="h-3.5 w-3.5" /> : <EyeIcon className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<p className="font-medium text-foreground text-sm">
<Trans>Features</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="mt-0.5 text-muted-foreground text-xs">
{enabledFlags.length > 0 ? (
enabledFlags
.map(
@@ -168,22 +180,21 @@ const AdminLicenseResyncButton = () => {
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { mutate: resyncLicense, isPending: isResyncingLicense } =
trpc.admin.license.resync.useMutation({
onSuccess: async () => {
toast({
title: t`License synced`,
});
const { mutate: resyncLicense, isPending: isResyncingLicense } = trpc.admin.license.resync.useMutation({
onSuccess: async () => {
toast({
title: t`License synced`,
});
await revalidate();
},
onError: () => {
toast({
title: t`Failed to sync license`,
variant: 'destructive',
});
},
});
await revalidate();
},
onError: () => {
toast({
title: t`Failed to sync license`,
variant: 'destructive',
});
},
});
return (
<TooltipProvider>
@@ -1,12 +1,11 @@
import type { TCachedLicense } from '@documenso/lib/types/license';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon, KeyRoundIcon } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type AdminLicenseStatusBannerProps = {
license: TCachedLicense | null;
};
@@ -21,36 +20,27 @@ export const AdminLicenseStatusBanner = ({ license }: AdminLicenseStatusBannerPr
return (
<div
className={cn('mb-8 rounded-lg bg-yellow-200 text-yellow-900 dark:bg-yellow-400', {
'bg-destructive text-destructive-foreground':
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
'bg-destructive text-destructive-foreground': licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
})}
>
<div className="flex items-center justify-between gap-x-4 px-4 py-3 text-sm font-medium">
<div className="flex items-center justify-between gap-x-4 px-4 py-3 font-medium text-sm">
<div className="flex items-center">
<AlertTriangleIcon className="mr-2.5 h-5 w-5" />
{match(licenseStatus)
.with('PAST_DUE', () => (
<Trans>
License Payment Overdue - Please update your payment to avoid service disruptions.
</Trans>
<Trans>License Payment Overdue - Please update your payment to avoid service disruptions.</Trans>
))
.with('EXPIRED', () => (
<Trans>
License Expired - Please renew your license to continue using enterprise features.
</Trans>
<Trans>License Expired - Please renew your license to continue using enterprise features.</Trans>
))
.with('UNAUTHORIZED', () =>
license ? (
<Trans>
Invalid License Type - Your Documenso instance is using features that are not part
of your license.
Invalid License Type - Your Documenso instance is using features that are not part of your license.
</Trans>
) : (
<Trans>
Missing License - Your Documenso instance is using features that require a
license.
</Trans>
<Trans>Missing License - Your Documenso instance is using features that require a license.</Trans>
),
)
.otherwise(() => null)}
@@ -1,10 +1,9 @@
import type { GetMonthlyActiveUsersResult } from '@documenso/lib/server-only/admin/get-users-stats';
import { DateTime } from 'luxon';
import type { TooltipProps } from 'recharts';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
import type { GetMonthlyActiveUsersResult } from '@documenso/lib/server-only/admin/get-users-stats';
export type MonthlyActiveUsersChartProps = {
className?: string;
title: string;
@@ -44,9 +43,9 @@ export const MonthlyActiveUsersChart = ({
return (
<div className={className}>
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2">
<div className="flex flex-1 flex-col justify-center rounded-2xl border border-border p-6 pl-2">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">{title}</h3>
<h3 className="font-semibold text-lg">{title}</h3>
</div>
<ResponsiveContainer width="100%" height={400}>
@@ -1,8 +1,7 @@
import type { GetSignerConversionMonthlyResult } from '@documenso/lib/server-only/user/get-signer-conversion';
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetSignerConversionMonthlyResult } from '@documenso/lib/server-only/user/get-signer-conversion';
export type AdminStatsSignerConversionChartProps = {
className?: string;
title: string;
@@ -26,9 +25,9 @@ export const AdminStatsSignerConversionChart = ({
return (
<div className={className}>
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2">
<div className="flex flex-1 flex-col justify-center rounded-2xl border border-border p-6 pl-2">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">{title}</h3>
<h3 className="font-semibold text-lg">{title}</h3>
</div>
<ResponsiveContainer width="100%" height={400}>
@@ -40,10 +39,7 @@ export const AdminStatsSignerConversionChart = ({
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
formatter={(value, name) => [
Number(value).toLocaleString('en-US'),
name === 'Recipients',
]}
formatter={(value, name) => [Number(value).toLocaleString('en-US'), name === 'Recipients']}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
@@ -1,9 +1,8 @@
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { TooltipProps } from 'recharts';
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
import type { GetUserWithDocumentMonthlyGrowth } from '@documenso/lib/server-only/admin/get-users-stats';
import { DateTime } from 'luxon';
import type { TooltipProps } from 'recharts';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
export type AdminStatsUsersWithDocumentsChartProps = {
className?: string;
@@ -60,9 +59,9 @@ export const AdminStatsUsersWithDocumentsChart = ({
return (
<div className={className}>
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2">
<div className="flex flex-1 flex-col justify-center rounded-2xl border border-border p-6 pl-2">
<div className="mb-6 flex h-12 px-4">
<h3 className="text-lg font-semibold">{title}</h3>
<h3 className="font-semibold text-lg">{title}</h3>
</div>
<ResponsiveContainer width="100%" height={400}>
@@ -78,13 +77,7 @@ export const AdminStatsUsersWithDocumentsChart = ({
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={60}
label={tooltip}
/>
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} maxBarSize={60} label={tooltip} />
</BarChart>
</ResponsiveContainer>
</div>
@@ -1,18 +1,14 @@
import { useEffect, useState } from 'react';
import { cn } from '@documenso/ui/lib/utils';
import { SearchIcon } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { useEffect, useState } from 'react';
export type AnimatedDocumentScannerProps = {
className?: string;
interval?: number;
};
export const AnimatedDocumentScanner = ({
className,
interval = 2500,
}: AnimatedDocumentScannerProps) => {
export const AnimatedDocumentScanner = ({ className, interval = 2500 }: AnimatedDocumentScannerProps) => {
const [magPosition, setMagPosition] = useState({ x: 0, y: 0, page: 0 });
useEffect(() => {
@@ -46,7 +42,7 @@ export const AnimatedDocumentScanner = ({
<div className="relative h-full w-full" style={{ transformStyle: 'preserve-3d' }}>
{/* Left page */}
<div
className="absolute left-0 top-0 h-full w-[calc(50%)] origin-right overflow-hidden rounded-l-md border border-border bg-card shadow-md"
className="absolute top-0 left-0 h-full w-[calc(50%)] origin-right overflow-hidden rounded-l-md border border-border bg-card shadow-md"
style={{ transform: 'rotateY(15deg) skewY(-1deg)' }}
>
<div className="absolute inset-3 space-y-2">
@@ -54,13 +50,13 @@ export const AnimatedDocumentScanner = ({
<div className="h-1.5 w-full rounded-sm bg-muted" />
<div className="h-1.5 w-5/6 rounded-sm bg-muted" />
<div className="h-1.5 w-2/3 rounded-sm bg-muted" />
<div className="mt-3 h-6 w-3/4 rounded border border-dashed border-primary" />
<div className="mt-3 h-6 w-3/4 rounded border border-primary border-dashed" />
</div>
</div>
{/* Right page */}
<div
className="absolute right-0 top-0 h-full w-[calc(50%)] origin-left overflow-hidden rounded-r-md border border-border bg-card shadow-md"
className="absolute top-0 right-0 h-full w-[calc(50%)] origin-left overflow-hidden rounded-r-md border border-border bg-card shadow-md"
style={{ transform: 'rotateY(-15deg) skewY(1deg)' }}
>
<div className="absolute inset-3 space-y-2">
@@ -68,7 +64,7 @@ export const AnimatedDocumentScanner = ({
<div className="h-1.5 w-4/5 rounded-sm bg-muted" />
<div className="h-1.5 w-full rounded-sm bg-muted" />
<div className="h-1.5 w-3/5 rounded-sm bg-muted" />
<div className="mt-3 h-6 w-2/3 rounded border border-dashed border-primary" />
<div className="mt-3 h-6 w-2/3 rounded border border-primary border-dashed" />
</div>
</div>
</div>
@@ -1,4 +1,4 @@
import { type TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
import type { TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
export type AppBannerProps = {
banner: TSiteSettingsBannerSchema;
@@ -12,7 +12,7 @@ export const AppBanner = ({ banner }: AppBannerProps) => {
return (
<div className="mb-2" style={{ background: banner.data.bgColor }}>
<div
className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium"
className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 font-medium text-sm"
style={{ color: banner.data.textColor }}
>
<div className="flex items-center">
@@ -1,14 +1,3 @@
import { useCallback, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router';
import { Theme, useTheme } from 'remix-themes';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
@@ -17,10 +6,7 @@ import {
SETTINGS_PAGE_SHORTCUT,
TEMPLATES_PAGE_SHORTCUT,
} from '@documenso/lib/constants/keyboard-shortcuts';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc as trpcReact } from '@documenso/trpc/react';
@@ -35,6 +21,16 @@ import {
CommandShortcut,
} from '@documenso/ui/primitives/command';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { keepPreviousData } from '@tanstack/react-query';
import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router';
import { Theme, useTheme } from 'remix-themes';
import { useOptionalCurrentTeam } from '~/providers/team';
@@ -65,20 +61,33 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
const [pages, setPages] = useState<string[]>([]);
const debouncedSearch = useDebouncedValue(search, 200);
const hasValidSearch = debouncedSearch.trim().length > 0;
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
trpcReact.document.search.useQuery(
{
query: debouncedSearch,
},
{
placeholderData: (previousData) => previousData,
// Do not batch this due to relatively long request time compared to
// other queries which are generally batched with this.
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const { data: searchDocumentsData, isFetching: isFetchingDocuments } = trpcReact.document.search.useQuery(
{
query: debouncedSearch,
},
{
enabled: open === true && hasValidSearch,
placeholderData: keepPreviousData,
// Do not batch this due to relatively long request time compared to
// other queries which are generally batched with this.
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const { data: searchTemplatesData, isFetching: isFetchingTemplates } = trpcReact.template.search.useQuery(
{
query: debouncedSearch,
},
{
enabled: open === true && hasValidSearch,
placeholderData: keepPreviousData,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const teamUrl = useMemo(() => {
let teamUrl = currentTeam?.url || null;
@@ -134,17 +143,23 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
];
}, [currentTeam, organisations]);
const searchResults = useMemo(() => {
if (!searchDocumentsData) {
return [];
}
const documentSearchResults =
hasValidSearch && searchDocumentsData
? searchDocumentsData.map((document) => ({
label: document.title,
path: document.path,
value: document.value,
}))
: [];
return searchDocumentsData.map((document) => ({
label: document.title,
path: document.path,
value: document.value,
}));
}, [searchDocumentsData]);
const templateSearchResults =
hasValidSearch && searchTemplatesData
? searchTemplatesData.map((template) => ({
label: template.title,
path: template.path,
value: template.value,
}))
: [];
const currentPage = pages[pages.length - 1];
@@ -215,26 +230,12 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
open={open}
onOpenChange={setOpen}
>
<CommandInput
value={search}
onValueChange={setSearch}
placeholder={_(msg`Type a command or search...`)}
/>
<CommandInput value={search} onValueChange={setSearch} placeholder={_(msg`Type a command or search...`)} />
<CommandList>
{isSearchingDocuments ? (
<CommandEmpty>
<div className="flex items-center justify-center">
<span className="animate-spin">
<Loader />
</span>
</div>
</CommandEmpty>
) : (
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
)}
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
{!currentPage && (
<>
@@ -256,16 +257,34 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
Change language
{_(msg`Change language`)}
</CommandItem>
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('theme')}>
Change theme
{_(msg`Change theme`)}
</CommandItem>
</CommandGroup>
{searchResults.length > 0 && (
{(isFetchingDocuments || documentSearchResults.length > 0) && (
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
<Commands push={push} pages={searchResults} />
{isFetchingDocuments ? (
<div className="flex items-center justify-center py-2">
<Loader className="h-4 w-4 animate-spin" />
</div>
) : (
<Commands push={push} pages={documentSearchResults} />
)}
</CommandGroup>
)}
{(isFetchingTemplates || templateSearchResults.length > 0) && (
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your templates`)}>
{isFetchingTemplates ? (
<div className="flex items-center justify-center py-2">
<Loader className="h-4 w-4 animate-spin" />
</div>
) : (
<Commands push={push} pages={templateSearchResults} />
)}
</CommandGroup>
)}
</>
@@ -315,7 +334,7 @@ const ThemeCommands = () => {
<CommandItem
key={theme.theme}
onSelect={() => setTheme(theme.theme)}
className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2"
className="mx-2 -my-1 rounded-lg first:mt-2 last:mb-2"
>
<theme.icon className="mr-2" />
{_(theme.label)}
@@ -367,15 +386,12 @@ const LanguageCommands = () => {
return Object.values(SUPPORTED_LANGUAGES).map((language) => (
<CommandItem
disabled={isLoading}
key={language.full}
key={language.short}
onSelect={async () => setLanguage(language.short)}
className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2"
className="mx-2 -my-1 rounded-lg first:mt-2 last:mb-2"
>
<CheckIcon
className={cn('mr-2 h-4 w-4', i18n.locale === language.short ? 'opacity-100' : 'opacity-0')}
/>
{language.full}
<CheckIcon className={cn('mr-2 h-4 w-4', i18n.locale === language.short ? 'opacity-100' : 'opacity-0')} />
{_(language.full)}
</CommandItem>
));
};
@@ -1,15 +1,13 @@
import { type HTMLAttributes, useEffect, useState } from 'react';
import { ReadStatus } from '@prisma/client';
import { InboxIcon, MenuIcon, SearchIcon } from 'lucide-react';
import { Link, useParams } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { getRootHref } from '@documenso/lib/utils/params';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { ReadStatus } from '@prisma/client';
import { InboxIcon, MenuIcon, SearchIcon } from 'lucide-react';
import { type HTMLAttributes, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { BrandingLogo } from '~/components/general/branding-logo';
@@ -52,7 +50,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
return (
<header
className={cn(
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
'sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent bg-background/95 backdrop-blur duration-200 supports-backdrop-blur:bg-background/60',
scrollY > 5 && 'border-b-border',
className,
)}
@@ -61,7 +59,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
<Link
to={getRootHref(params)}
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
className="hidden rounded-md ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 md:inline"
>
<BrandingLogo className="h-6 w-auto" />
</Link>
@@ -70,35 +68,30 @@ export const Header = ({ className, ...props }: HeaderProps) => {
<Button asChild variant="outline" className="relative hidden h-10 w-10 rounded-lg md:flex">
<Link to="/inbox" className="relative block h-10 w-10">
<InboxIcon className="text-muted-foreground hover:text-foreground h-5 w-5 flex-shrink-0 transition-colors" />
<InboxIcon className="h-5 w-5 flex-shrink-0 text-muted-foreground transition-colors hover:text-foreground" />
{unreadCountData && unreadCountData.count > 0 && (
<span className="bg-primary text-primary-foreground absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold">
<span className="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary font-semibold text-[10px] text-primary-foreground">
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
</span>
)}
</Link>
</Button>
<div className="md:ml-4">
{isPersonalLayout(organisations) ? <MenuSwitcher /> : <OrgMenuSwitcher />}
</div>
<div className="md:ml-4">{isPersonalLayout(organisations) ? <MenuSwitcher /> : <OrgMenuSwitcher />}</div>
<div className="flex flex-row items-center space-x-4 md:hidden">
<button onClick={() => setIsCommandMenuOpen(true)}>
<SearchIcon className="text-muted-foreground h-6 w-6" />
<SearchIcon className="h-6 w-6 text-muted-foreground" />
</button>
<button onClick={() => setIsHamburgerMenuOpen(true)}>
<MenuIcon className="text-muted-foreground h-6 w-6" />
<MenuIcon className="h-6 w-6 text-muted-foreground" />
</button>
<AppCommandMenu open={isCommandMenuOpen} onOpenChange={setIsCommandMenuOpen} />
<AppNavMobile
isMenuOpen={isHamburgerMenuOpen}
onMenuOpenChange={setIsHamburgerMenuOpen}
/>
<AppNavMobile isMenuOpen={isHamburgerMenuOpen} onMenuOpenChange={setIsHamburgerMenuOpen} />
</div>
</div>
</header>
@@ -1,18 +1,15 @@
import type { HTMLAttributes } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { motion } from 'framer-motion';
import { AnimatePresence } from 'framer-motion';
import { Search } from 'lucide-react';
import { Link, useLocation } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { Search } from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router';
import { useOptionalCurrentTeam } from '~/providers/team';
@@ -20,11 +17,7 @@ export type AppNavDesktopProps = HTMLAttributes<HTMLDivElement> & {
setIsCommandMenuOpen: (value: boolean) => void;
};
export const AppNavDesktop = ({
className,
setIsCommandMenuOpen,
...props
}: AppNavDesktopProps) => {
export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: AppNavDesktopProps) => {
const { _ } = useLingui();
const { organisations } = useSession();
@@ -65,13 +58,7 @@ export const AppNavDesktop = ({
}, [currentTeam, organisations]);
return (
<div
className={cn(
'ml-8 hidden flex-1 items-center gap-x-12 md:flex md:justify-between',
className,
)}
{...props}
>
<div className={cn('ml-8 hidden flex-1 items-center gap-x-12 md:flex md:justify-between', className)} {...props}>
<div>
<AnimatePresence>
{menuNavigationLinks.length > 0 && (
@@ -86,7 +73,7 @@ export const AppNavDesktop = ({
key={href}
to={href}
className={cn(
'text-muted-foreground dark:text-muted-foreground/60 focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
'rounded-md font-medium text-muted-foreground leading-5 ring-offset-background hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:text-muted-foreground/60',
{
'text-foreground dark:text-muted-foreground': pathname?.startsWith(href),
},
@@ -102,7 +89,7 @@ export const AppNavDesktop = ({
<Button
variant="outline"
className="text-muted-foreground flex w-full max-w-96 items-center justify-between rounded-lg"
className="flex w-full max-w-96 items-center justify-between rounded-lg text-muted-foreground"
onClick={() => setIsCommandMenuOpen(true)}
>
<div className="flex items-center">
@@ -111,7 +98,7 @@ export const AppNavDesktop = ({
</div>
<div>
<div className="text-muted-foreground bg-muted flex items-center rounded-md px-1.5 py-0.5 text-xs tracking-wider">
<div className="flex items-center rounded-md bg-muted px-1.5 py-0.5 text-muted-foreground text-xs tracking-wider">
{modifierKey}+K
</div>
</div>
@@ -1,10 +1,3 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { ReadStatus } from '@prisma/client';
import { Link } from 'react-router';
import LogoImage from '@documenso/assets/logo.png';
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
@@ -12,6 +5,10 @@ import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
import { Trans, useLingui } from '@lingui/react/macro';
import { ReadStatus } from '@prisma/client';
import { useMemo } from 'react';
import { Link } from 'react-router';
import { useOptionalCurrentTeam } from '~/providers/team';
@@ -84,26 +81,20 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<SheetContent className="flex w-full max-w-[350px] flex-col">
<Link to="/" onClick={handleMenuItemClick}>
<img
src={LogoImage}
alt="Documenso Logo"
className="dark:invert"
width={170}
height={25}
/>
<img src={LogoImage} alt="Documenso Logo" className="dark:invert" width={170} height={25} />
</Link>
<div className="mt-8 flex w-full flex-col items-start gap-y-4">
{menuNavigationLinks.map(({ href, text }) => (
<Link
key={href}
className="text-foreground hover:text-foreground/80 flex items-center gap-2 text-2xl font-semibold"
className="flex items-center gap-2 font-semibold text-2xl text-foreground hover:text-foreground/80"
to={href}
onClick={() => handleMenuItemClick()}
>
{text}
{href === '/inbox' && unreadCountData && unreadCountData.count > 0 && (
<span className="bg-primary text-primary-foreground flex h-6 min-w-[1.5rem] items-center justify-center rounded-full px-1.5 text-xs font-semibold">
<span className="flex h-6 min-w-[1.5rem] items-center justify-center rounded-full bg-primary px-1.5 font-semibold text-primary-foreground text-xs">
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
</span>
)}
@@ -111,7 +102,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
))}
<button
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
className="font-semibold text-2xl text-foreground hover:text-foreground/80"
onClick={async () => authClient.signOut()}
>
<Trans>Sign Out</Trans>
@@ -124,7 +115,9 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
</div>
<p className="text-muted-foreground text-sm">
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
© {new Date().getFullYear()} Documenso, Inc.
<br />
<Trans>All rights reserved.</Trans>
</p>
</div>
</SheetContent>
@@ -1,20 +1,19 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { Recipient } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { DocumentStatus } from '@prisma/client';
import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = {
recipient: Recipient;
recipient: TRecipientLite;
documentStatus: DocumentStatus;
};
@@ -56,15 +55,11 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
/>
<div
className="text-sm text-muted-foreground"
title={
signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined
}
className="text-muted-foreground text-sm"
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
>
<p>{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
<p className="text-muted-foreground/70 text-xs">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
</div>
</div>
);
@@ -1,12 +1,3 @@
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { Building2Icon, PlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
@@ -26,19 +17,18 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { Building2Icon, PlusIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { ZCreateOrganisationFormSchema } from '../dialogs/organisation-create-dialog';
@@ -75,10 +65,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
return (
<div>
<Tabs
value={interval}
onValueChange={(value) => setInterval(value as 'monthlyPrice' | 'yearlyPrice')}
>
<Tabs value={interval} onValueChange={(value) => setInterval(value as 'monthlyPrice' | 'yearlyPrice')}>
<TabsList>
<TabsTrigger className="min-w-[150px]" value="monthlyPrice">
<Trans>Monthly</Trans>
@@ -101,24 +88,20 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
<CardContent className="flex h-full flex-col p-6">
<CardTitle>{price.product.name}</CardTitle>
<div className="mt-2 text-lg font-medium text-muted-foreground">
<div className="mt-2 font-medium text-lg text-muted-foreground">
{price.friendlyPrice + ' '}
<span className="text-xs">
{interval === 'monthlyPrice' ? (
<Trans>per month</Trans>
) : (
<Trans>per year</Trans>
)}
{interval === 'monthlyPrice' ? <Trans>per month</Trans> : <Trans>per year</Trans>}
</span>
</div>
<div className="mt-1.5 text-sm text-muted-foreground">
{price.product.description}
</div>
<div className="mt-1.5 text-muted-foreground text-sm">{price.product.description}</div>
{price.product.features && price.product.features.length > 0 && (
<div className="mt-4 text-muted-foreground">
<div className="text-sm font-medium">Includes:</div>
<div className="font-medium text-sm">
<Trans>Includes:</Trans>
</div>
<ul className="mt-1 divide-y text-sm">
{price.product.features.map((feature, index) => (
@@ -171,9 +154,7 @@ const BillingDialog = ({
const organisation = useCurrentOrganisation();
const [subscriptionOption, setSubscriptionOption] = useState<'update' | 'create'>(
organisation.type === 'PERSONAL' && claim === INTERNAL_CLAIM_ID.INDIVIDUAL
? 'update'
: 'create',
organisation.type === 'PERSONAL' && claim === INTERNAL_CLAIM_ID.INDIVIDUAL ? 'update' : 'create',
);
const [step, setStep] = useState(0);
@@ -188,8 +169,7 @@ const BillingDialog = ({
const { mutateAsync: createSubscription, isPending: isCreatingSubscription } =
trpc.enterprise.billing.subscription.create.useMutation();
const { mutateAsync: createOrganisation, isPending: isCreatingOrganisation } =
trpc.organisation.create.useMutation();
const { mutateAsync: createOrganisation, isPending: isCreatingOrganisation } = trpc.organisation.create.useMutation();
const isPending = isCreatingSubscription || isCreatingOrganisation;
@@ -261,7 +241,7 @@ const BillingDialog = ({
<Building2Icon className="h-4 w-4" />
<Trans>Update current organisation</Trans>
</Label>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Trans>
Upgrade <strong>{organisation.name}</strong> to {planName}
</Trans>
@@ -276,10 +256,10 @@ const BillingDialog = ({
<PlusIcon className="h-4 w-4" />
<Trans>Create separate organisation</Trans>
</Label>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Trans>
Create a new organisation with {planName} plan. Keep your current organisation
on it's current plan
Create a new organisation with {planName} plan. Keep your current organisation on it's current
plan
</Trans>
</p>
</div>
@@ -345,8 +325,7 @@ export const IndividualPersonalLayoutCheckoutButton = ({
const { toast } = useToast();
const { organisations } = useSession();
const { mutateAsync: createSubscription, isPending } =
trpc.enterprise.billing.subscription.create.useMutation();
const { mutateAsync: createSubscription, isPending } = trpc.enterprise.billing.subscription.create.useMutation();
const onSubscribeClick = async () => {
try {
@@ -1,3 +1,13 @@
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError } from '@documenso/lib/errors/app-error';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -6,24 +16,7 @@ import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { signupErrorMessages } from '~/components/forms/signup';
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
export type ClaimAccountProps = {
defaultName: string;
@@ -33,11 +26,8 @@ export type ClaimAccountProps = {
export const ZClaimAccountFormSchema = z
.object({
name: z
.string()
.trim()
.min(1, { message: msg`Please enter a valid name.`.id }),
email: z.string().email().min(1),
name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }),
email: zEmail().min(1),
password: ZPasswordSchema,
})
.refine(
@@ -90,7 +80,7 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
const errorMessage = SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST;
toast({
title: _(msg`An error occurred`),
@@ -1,13 +1,12 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans, useLingui as useLinguiMacro } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import type { TDefaultRecipient } from '@documenso/lib/types/default-recipients';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans, useLingui as useLinguiMacro } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
type DefaultRecipientsMultiSelectComboboxProps = {
listValues: TDefaultRecipient[];
@@ -26,18 +25,17 @@ export const DefaultRecipientsMultiSelectCombobox = ({
const { t } = useLinguiMacro();
const { toast } = useToast();
const { data: organisationData, isLoading: isLoadingOrganisation } =
trpc.organisation.member.find.useQuery(
{
organisationId: organisationId!,
query: '',
page: 1,
perPage: 100,
},
{
enabled: !!organisationId,
},
);
const { data: organisationData, isLoading: isLoadingOrganisation } = trpc.organisation.member.find.useQuery(
{
organisationId: organisationId!,
query: '',
page: 1,
perPage: 100,
},
{
enabled: !!organisationId,
},
);
const { data: teamData, isLoading: isLoadingTeam } = trpc.team.member.find.useQuery(
{
@@ -65,9 +63,7 @@ export const DefaultRecipientsMultiSelectCombobox = ({
}));
const onSelectionChange = (selected: Option[]) => {
const invalidEmails = selected.filter(
(option) => !isRecipientEmailValidForSending({ email: option.value }),
);
const invalidEmails = selected.filter((option) => !isRecipientEmailValidForSending({ email: option.value }));
if (invalidEmails.length > 0) {
toast({
@@ -1,12 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import type { Field } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TTemplate } from '@documenso/lib/types/template';
import { zEmail } from '@documenso/lib/utils/zod';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
@@ -19,21 +13,19 @@ import {
DocumentFlowFormContainerStep,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useStep } from '@documenso/ui/primitives/stepper';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
const ZDirectTemplateConfigureFormSchema = z.object({
email: z.string().email('Email is invalid'),
email: zEmail('Email is invalid'),
});
export type TDirectTemplateConfigureFormSchema = z.infer<typeof ZDirectTemplateConfigureFormSchema>;
@@ -88,20 +80,14 @@ export const DirectTemplateConfigureForm = ({
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded && (
<DocumentReadOnlyFields
fields={mapFieldsWithRecipients(
directTemplateRecipient.fields,
recipientsWithBlankDirectRecipientEmail,
)}
fields={mapFieldsWithRecipients(directTemplateRecipient.fields, recipientsWithBlankDirectRecipientEmail)}
recipientIds={recipients.map((recipient) => recipient.id)}
showRecipientColors={true}
/>
)}
<Form {...form}>
<fieldset
className="flex h-full flex-col space-y-6"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-6" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="email"
@@ -114,17 +100,13 @@ export const DirectTemplateConfigureForm = ({
<FormControl>
<Input
{...field}
disabled={
field.disabled ||
derivedRecipientAccessAuth.length > 0 ||
user?.email !== undefined
}
disabled={field.disabled || derivedRecipientAccessAuth.length > 0 || user?.email !== undefined}
placeholder="recipient@documenso.com"
/>
</FormControl>
{!fieldState.error && (
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
<Trans>Enter your email address to receive the completed document.</Trans>
</p>
)}
@@ -1,33 +1,25 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { Field } from '@prisma/client';
import { type Recipient } from '@prisma/client';
import { useNavigate, useSearchParams } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TTemplate } from '@documenso/lib/types/template';
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { Field, Recipient } from '@prisma/client';
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import {
DirectTemplateConfigureForm,
type TDirectTemplateConfigureFormSchema,
} from './direct-template-configure-form';
import {
type DirectTemplateLocalField,
DirectTemplateSigningForm,
} from './direct-template-signing-form';
import { DirectTemplateConfigureForm, type TDirectTemplateConfigureFormSchema } from './direct-template-configure-form';
import { type DirectTemplateLocalField, DirectTemplateSigningForm } from './direct-template-signing-form';
export type DirectTemplatePageViewProps = {
template: Omit<TTemplate, 'user'>;
@@ -55,9 +47,7 @@ export const DirectTemplatePageView = ({
const [step, setStep] = useState<DirectTemplateStep>('configure');
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
const recipientActionVerb = _(
RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role].actionVerb,
);
const recipientActionVerb = _(RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role].actionVerb);
const directTemplateFlow: Record<DirectTemplateStep, DocumentFlowStep> = {
configure: {
@@ -132,9 +122,7 @@ export const DirectTemplatePageView = ({
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We were unable to submit this document at this time. Please try again later.`,
),
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
variant: 'destructive',
});
@@ -146,26 +134,26 @@ export const DirectTemplatePageView = ({
return (
<div className="grid w-full grid-cols-12 gap-8">
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<Card className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7" gradient>
<CardContent className="p-2">
<PDFViewerLazy
key={template.id}
envelopeItem={template.envelopeItems[0]}
token={directTemplateRecipient.token}
version="signed"
data={getDocumentDataUrlForPdfViewer({
envelopeId: template.envelopeId,
envelopeItemId: template.envelopeItems[0]?.id,
documentDataId: template.templateDocumentDataId,
version: 'current',
token: directTemplateRecipient.token,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()}
>
<DocumentFlowFormContainer className="lg:h-[calc(100vh-6rem)]" onSubmit={(e) => e.preventDefault()}>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(DirectTemplateSteps[step - 1])}
@@ -1,12 +1,10 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { authClient } from '@documenso/auth/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export const DirectTemplateAuthPageView = () => {
const { _ } = useLingui();
@@ -34,11 +32,11 @@ export const DirectTemplateAuthPageView = () => {
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
<h1 className="text-3xl font-semibold">
<h1 className="font-semibold text-3xl">
<Trans>Authentication required</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<p className="mt-2 text-muted-foreground text-sm">
<Trans>You need to be logged in to view this page.</Trans>
</p>
@@ -1,11 +1,3 @@
import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient, Signature } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
@@ -37,6 +29,12 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useStep } from '@documenso/ui/primitives/stepper';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient, Signature } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { DateTime } from 'luxon';
import { useEffect, useMemo, useState } from 'react';
import { match } from 'ts-pattern';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
import { DocumentSigningCompleteDialog } from '~/components/general/document-signing/document-signing-complete-dialog';
@@ -58,10 +56,7 @@ export type DirectTemplateSigningFormProps = {
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'>;
directRecipientFields: Field[];
template: Omit<TTemplate, 'user'>;
onSubmit: (
_data: DirectTemplateLocalField[],
_nextSigner?: { name: string; email: string },
) => Promise<void>;
onSubmit: (_data: DirectTemplateLocalField[], _nextSigner?: { name: string; email: string }) => Promise<void>;
};
export type DirectTemplateLocalField = Field & {
@@ -82,8 +77,6 @@ export const DirectTemplateSigningForm = ({
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const highestPageNumber = Math.max(...localFields.map((field) => field.page));
const fieldsRequiringValidation = useMemo(() => {
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
}, [localFields]);
@@ -232,10 +225,18 @@ export const DirectTemplateSigningForm = ({
const sortedRecipients = template.recipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
});
@@ -250,9 +251,7 @@ export const DirectTemplateSigningForm = ({
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
<Trans>Click to insert field</Trans>
@@ -307,9 +306,7 @@ export const DirectTemplateSigningForm = ({
/>
))
.with(FieldType.TEXT, () => {
const parsedFieldMeta = field.fieldMeta
? ZTextFieldMeta.parse(field.fieldMeta)
: null;
const parsedFieldMeta = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null;
return (
<DocumentSigningTextField
@@ -324,9 +321,7 @@ export const DirectTemplateSigningForm = ({
);
})
.with(FieldType.NUMBER, () => {
const parsedFieldMeta = field.fieldMeta
? ZNumberFieldMeta.parse(field.fieldMeta)
: null;
const parsedFieldMeta = field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null;
return (
<DocumentSigningNumberField
@@ -341,9 +336,7 @@ export const DirectTemplateSigningForm = ({
);
})
.with(FieldType.DROPDOWN, () => {
const parsedFieldMeta = field.fieldMeta
? ZDropdownFieldMeta.parse(field.fieldMeta)
: null;
const parsedFieldMeta = field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null;
return (
<DocumentSigningDropdownField
@@ -358,9 +351,7 @@ export const DirectTemplateSigningForm = ({
);
})
.with(FieldType.RADIO, () => {
const parsedFieldMeta = field.fieldMeta
? ZRadioFieldMeta.parse(field.fieldMeta)
: null;
const parsedFieldMeta = field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null;
return (
<DocumentSigningRadioField
@@ -375,9 +366,7 @@ export const DirectTemplateSigningForm = ({
);
})
.with(FieldType.CHECKBOX, () => {
const parsedFieldMeta = field.fieldMeta
? ZCheckboxFieldMeta.parse(field.fieldMeta)
: null;
const parsedFieldMeta = field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null;
return (
<DocumentSigningCheckboxField
@@ -402,11 +391,7 @@ export const DirectTemplateSigningForm = ({
<Trans>Full Name</Trans>
</Label>
<Input
id="full-name"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
<Input id="full-name" value={fullName} onChange={(e) => setFullName(e.target.value.trimStart())} />
</div>
<div>
@@ -451,9 +436,7 @@ export const DirectTemplateSigningForm = ({
fieldsValidated={fieldsValidated}
recipient={directRecipient}
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined}
/>
</div>
</DocumentFlowFormContainerFooter>
@@ -1,14 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ArrowLeftIcon, KeyIcon, MailIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -17,6 +6,15 @@ import { Button } from '@documenso/ui/primitives/button';
import { Form, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ArrowLeftIcon, KeyIcon, MailIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
@@ -148,7 +146,7 @@ export const AccessAuth2FAForm = ({ onSubmit, token, error }: AccessAuth2FAFormP
{step === 'method-selection' && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">
<h3 className="font-semibold text-lg">
<Trans>Choose verification method</Trans>
</h3>
<p className="text-muted-foreground text-sm">
@@ -211,7 +209,7 @@ export const AccessAuth2FAForm = ({ onSubmit, token, error }: AccessAuth2FAFormP
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h3 className="text-lg font-semibold">
<h3 className="font-semibold text-lg">
<Trans>Enter verification code</Trans>
</h3>
</div>
@@ -219,22 +217,15 @@ export const AccessAuth2FAForm = ({ onSubmit, token, error }: AccessAuth2FAFormP
<div className="text-muted-foreground text-sm">
{selectedMethod === 'email' ? (
<Trans>
We've sent a 6-digit verification code to your email. Please enter it below to
complete the document.
We've sent a 6-digit verification code to your email. Please enter it below to complete the document.
</Trans>
) : (
<Trans>
Please open your authenticator app and enter the 6-digit code for this document.
</Trans>
<Trans>Please open your authenticator app and enter the 6-digit code for this document.</Trans>
)}
</div>
<Form {...form}>
<form
id="access-auth-2fa-form"
className="space-y-4"
onSubmit={form.handleSubmit(onFormSubmit)}
>
<form id="access-auth-2fa-form" className="space-y-4" onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isRequesting2FAEmail || form.formState.isSubmitting}>
<FormField
control={form.control}
@@ -262,15 +253,12 @@ export const AccessAuth2FAForm = ({ onSubmit, token, error }: AccessAuth2FAFormP
{expiresAt && millisecondsRemaining !== null && (
<div
className={cn('text-muted-foreground mt-2 text-center text-sm', {
className={cn('mt-2 text-center text-muted-foreground text-sm', {
'text-destructive': millisecondsRemaining <= 0,
})}
>
<Trans>
Expires in{' '}
{DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat(
'mm:ss',
)}
Expires in {DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat('mm:ss')}
</Trans>
</div>
)}
@@ -1,9 +1,8 @@
import { Trans } from '@lingui/react/macro';
import { ExternalLink, PaperclipIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Trans } from '@lingui/react/macro';
import { ExternalLink, PaperclipIcon } from 'lucide-react';
export type DocumentSigningAttachmentsPopoverProps = {
envelopeId: string;
@@ -33,9 +32,7 @@ export const DocumentSigningAttachmentsPopover = ({
<PaperclipIcon className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>{' '}
{attachments && attachments.data.length > 0 && (
<span className="ml-1">({attachments.data.length})</span>
)}
{attachments && attachments.data.length > 0 && <span className="ml-1">({attachments.data.length})</span>}
</span>
</Button>
)}
@@ -47,7 +44,7 @@ export const DocumentSigningAttachmentsPopover = ({
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="text-muted-foreground mt-1 text-sm">
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Documents and resources related to this envelope.</Trans>
</p>
</div>
@@ -60,14 +57,14 @@ export const DocumentSigningAttachmentsPopover = ({
title={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="border-border hover:bg-muted/50 group flex items-center justify-between rounded-md border px-3 py-2.5 transition duration-200"
className="group flex items-center justify-between rounded-md border border-border px-3 py-2.5 transition duration-200 hover:bg-muted/50"
>
<div className="flex flex-1 items-center gap-2.5">
<div className="bg-muted rounded p-2">
<div className="rounded bg-muted p-2">
<PaperclipIcon className="h-4 w-4" />
</div>
<span className="text-muted-foreground hover:text-foreground block truncate text-sm underline">
<span className="block truncate text-muted-foreground text-sm underline hover:text-foreground">
{attachment.label}
</span>
</div>
@@ -1,25 +1,17 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
@@ -27,7 +19,6 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
export type DocumentSigningAuth2FAProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
@@ -44,7 +35,6 @@ type T2FAAuthFormSchema = z.infer<typeof Z2FAAuthFormSchema>;
export const DocumentSigningAuth2FA = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
@@ -101,18 +91,43 @@ export const DocumentSigningAuth2FA = ({
<Alert variant="warning">
<AlertDescription>
<p>
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? (
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
) : (
// Todo: Translate
`You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`
)}
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to sign this field.</Trans>
))
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to sign this document.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to approve this field.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to approve this document.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to view this field.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to view this field.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to view this document.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to assist with this field.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to assist with this document.</Trans>
))
.exhaustive()}
</p>
<p className="mt-2">
<Trans>
By enabling 2FA, you will be required to enter a code from your authenticator app
every time you sign in using email password.
By enabling 2FA, you will be required to enter a code from your authenticator app every time you sign in
using email password.
</Trans>
</p>
</AlertDescription>
@@ -138,7 +153,9 @@ export const DocumentSigningAuth2FA = ({
name="token"
render={({ field }) => (
<FormItem>
<FormLabel required>2FA token</FormLabel>
<FormLabel required>
<Trans>2FA token</Trans>
</FormLabel>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
@@ -163,9 +180,7 @@ export const DocumentSigningAuth2FA = ({
<Trans>Unauthorized</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
We were unable to verify your details. Please try again or contact support
</Trans>
<Trans>We were unable to verify your details. Please try again or contact support</Trans>
</AlertDescription>
</Alert>
)}
@@ -1,25 +1,22 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { authClient } from '@documenso/auth/client';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { useState } from 'react';
import { match } from 'ts-pattern';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthAccountProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
onOpenChange: (value: boolean) => void;
};
export const DocumentSigningAuthAccount = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onOpenChange,
}: DocumentSigningAuthAccountProps) => {
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
@@ -55,32 +52,100 @@ export const DocumentSigningAuthAccount = ({
<fieldset disabled={isSigningOut} className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
<span>
{isDirectTemplate ? (
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
) : (
<Trans>
To mark this document as viewed, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
)}
</span>
) : (
<span>
{isDirectTemplate ? (
<Trans>
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
logged in.
</Trans>
) : (
<Trans>
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
logged in as <strong>{recipient.email}</strong>
</Trans>
)}
</span>
)}
<span>
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To sign this field, you need to be logged in.</Trans>
) : (
<Trans>
To sign this field, you need to be logged in as <strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To sign this document, you need to be logged in.</Trans>
) : (
<Trans>
To sign this document, you need to be logged in as <strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To approve this field, you need to be logged in.</Trans>
) : (
<Trans>
To approve this field, you need to be logged in as <strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To approve this document, you need to be logged in.</Trans>
) : (
<Trans>
To approve this document, you need to be logged in as <strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To view this field, you need to be logged in.</Trans>
) : (
<Trans>
To view this field, you need to be logged in as <strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
) : (
<Trans>
To mark this document as viewed, you need to be logged in as <strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To view this field, you need to be logged in.</Trans>
) : (
<Trans>
To view this field, you need to be logged in as <strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To view this document, you need to be logged in.</Trans>
) : (
<Trans>
To view this document, you need to be logged in as <strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To assist with this field, you need to be logged in.</Trans>
) : (
<Trans>
To assist with this field, you need to be logged in as <strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To assist with this document, you need to be logged in.</Trans>
) : (
<Trans>
To assist with this document, you need to be logged in as <strong>{recipient.email}</strong>
</Trans>
),
)
.exhaustive()}
</span>
</AlertDescription>
</Alert>
@@ -1,23 +1,15 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { P, match } from 'ts-pattern';
import {
DocumentAuth,
type TRecipientActionAuth,
type TRecipientActionAuthTypes,
} from '@documenso/lib/types/document-auth';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { useState } from 'react';
import { match, P } from 'ts-pattern';
import { DocumentSigningAuth2FA } from './document-signing-auth-2fa';
import { DocumentSigningAuthAccount } from './document-signing-auth-account';
@@ -48,13 +40,10 @@ export const DocumentSigningAuthDialog = ({
onOpenChange,
onReauthFormSubmit,
}: DocumentSigningAuthDialogProps) => {
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
useRequiredDocumentSigningAuthContext();
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
// Filter out EXPLICIT_NONE from available auth types for the chooser
const validAuthTypes = availableAuthTypes.filter(
(authType) => authType !== DocumentAuth.EXPLICIT_NONE,
);
const validAuthTypes = availableAuthTypes.filter((authType) => authType !== DocumentAuth.EXPLICIT_NONE);
const [selectedAuthType, setSelectedAuthType] = useState<TRecipientActionAuthTypes | null>(() => {
// Auto-select if there's only one valid option
@@ -93,20 +82,13 @@ export const DocumentSigningAuthDialog = ({
<DialogTitle>
{selectedAuthType && validAuthTypes.length > 1 && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleBackToChooser}
className="h-6 w-6 p-0"
>
<Button type="button" variant="ghost" size="sm" onClick={handleBackToChooser} className="h-6 w-6 p-0">
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<span>{title || <Trans>Sign field</Trans>}</span>
</div>
)}
{(!selectedAuthType || validAuthTypes.length === 1) &&
(title || <Trans>Sign field</Trans>)}
{(!selectedAuthType || validAuthTypes.length === 1) && (title || <Trans>Sign field</Trans>)}
</DialogTitle>
<DialogDescription>
@@ -117,7 +99,7 @@ export const DocumentSigningAuthDialog = ({
{/* Show chooser if no auth type is selected and there are multiple options */}
{!selectedAuthType && validAuthTypes.length > 1 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Trans>Choose your preferred authentication method:</Trans>
</p>
<div className="grid gap-2">
@@ -135,22 +117,16 @@ export const DocumentSigningAuthDialog = ({
.with(DocumentAuth.ACCOUNT, () => <Trans>Account</Trans>)
.with(DocumentAuth.PASSKEY, () => <Trans>Passkey</Trans>)
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>2FA</Trans>)
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => (
<Trans>Verification code</Trans>
))
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => <Trans>Verification code</Trans>)
.with(DocumentAuth.PASSWORD, () => <Trans>Password</Trans>)
.exhaustive()}
</div>
<div className="text-sm text-muted-foreground">
<div className="text-muted-foreground text-sm">
{match(authType)
.with(DocumentAuth.ACCOUNT, () => <Trans>Sign in to your account</Trans>)
.with(DocumentAuth.PASSKEY, () => (
<Trans>Use your passkey for authentication</Trans>
))
.with(DocumentAuth.TWO_FACTOR_AUTH, () => (
<Trans>Enter your 2FA code</Trans>
))
.with(DocumentAuth.PASSKEY, () => <Trans>Use your passkey for authentication</Trans>)
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>Enter your 2FA code</Trans>)
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => (
<Trans>Enter the verification code provided to you</Trans>
))
@@ -170,9 +146,7 @@ export const DocumentSigningAuthDialog = ({
.with(
{ documentAuthType: DocumentAuth.ACCOUNT },
{
user: P.when(
(user) => !user || (user.email !== recipient.email && !isDirectTemplate),
),
user: P.when((user) => !user || (user.email !== recipient.email && !isDirectTemplate)),
}, // Assume all current auth methods requires them to be logged in.
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
)
@@ -184,11 +158,7 @@ export const DocumentSigningAuthDialog = ({
/>
))
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
<DocumentSigningAuth2FA
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
<DocumentSigningAuth2FA open={open} onOpenChange={onOpenChange} onReauthFormSubmit={onReauthFormSubmit} />
))
.with({ documentAuthType: DocumentAuth.PASSWORD }, () => (
<DocumentSigningAuthPassword
@@ -1,22 +1,17 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { authClient } from '@documenso/auth/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type DocumentSigningAuthPageViewProps = {
email?: string;
emailHasAccount?: boolean;
};
export const DocumentSigningAuthPageView = ({
email,
emailHasAccount,
}: DocumentSigningAuthPageViewProps) => {
export const DocumentSigningAuthPageView = ({ email, emailHasAccount }: DocumentSigningAuthPageViewProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@@ -50,11 +45,11 @@ export const DocumentSigningAuthPageView = ({
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
<h1 className="text-3xl font-semibold">
<h1 className="font-semibold text-3xl">
<Trans>Authentication required</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<p className="mt-2 text-muted-foreground text-sm">
{email ? (
<Trans>
You need to be logged in as <strong>{email}</strong> to view this page.
@@ -1,5 +1,11 @@
import { useEffect, useState } from 'react';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -7,38 +13,17 @@ import { Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
import { Loader } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { PasskeyCreateDialog } from '~/components/dialogs/passkey-create-dialog';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthPasskeyProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
@@ -52,7 +37,6 @@ type TPasskeyAuthFormSchema = z.infer<typeof ZPasskeyAuthFormSchema>;
export const DocumentSigningAuthPasskey = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
@@ -128,9 +112,40 @@ export const DocumentSigningAuthPasskey = ({
<div className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{/* Todo: Translate */}
Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '}
this {actionTarget.toLowerCase()}.
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
<Trans>Your browser does not support passkeys, which is required to sign this field.</Trans>
))
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
<Trans>Your browser does not support passkeys, which is required to sign this document.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
<Trans>Your browser does not support passkeys, which is required to approve this field.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
<Trans>Your browser does not support passkeys, which is required to approve this document.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
<Trans>Your browser does not support passkeys, which is required to view this field.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to mark this document as viewed.
</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
<Trans>Your browser does not support passkeys, which is required to view this field.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
<Trans>Your browser does not support passkeys, which is required to view this document.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
<Trans>Your browser does not support passkeys, which is required to assist with this field.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
<Trans>Your browser does not support passkeys, which is required to assist with this document.</Trans>
))
.exhaustive()}
</AlertDescription>
</Alert>
@@ -178,10 +193,38 @@ export const DocumentSigningAuthPasskey = ({
<div className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{/* Todo: Translate */}
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
? 'You need to setup a passkey to mark this document as viewed.'
: `You need to setup a passkey to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to sign this field.</Trans>
))
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to sign this document.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to approve this field.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to approve this document.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to view this field.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to mark this document as viewed.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to view this field.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to view this document.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to assist with this field.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to assist with this document.</Trans>
))
.exhaustive()}
</AlertDescription>
</Alert>
@@ -213,15 +256,14 @@ export const DocumentSigningAuthPasskey = ({
name="passkeyId"
render={({ field }) => (
<FormItem>
<FormLabel required>Passkey</FormLabel>
<FormLabel required>
<Trans>Passkey</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue
data-testid="documentAccessSelectValue"
placeholder={_(msg`Select passkey`)}
/>
<SelectValue data-testid="documentAccessSelectValue" placeholder={_(msg`Select passkey`)} />
</SelectTrigger>
<SelectContent position="popper">
@@ -241,20 +283,22 @@ export const DocumentSigningAuthPasskey = ({
{formErrorCode && (
<Alert variant="destructive">
<AlertTitle>Unauthorized</AlertTitle>
<AlertTitle>
<Trans>Unauthorized</Trans>
</AlertTitle>
<AlertDescription>
We were unable to verify your details. Please try again or contact support
<Trans>We were unable to verify your details. Please try again or contact support</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
Sign
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</div>
@@ -1,30 +1,19 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthPasswordProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
@@ -40,8 +29,6 @@ const ZPasswordAuthFormSchema = z.object({
type TPasswordAuthFormSchema = z.infer<typeof ZPasswordAuthFormSchema>;
export const DocumentSigningAuthPassword = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
@@ -103,9 +90,7 @@ export const DocumentSigningAuthPassword = ({
<Trans>Unauthorized</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
We were unable to verify your details. Please try again or contact support
</Trans>
<Trans>We were unable to verify your details. Please try again or contact support</Trans>
</AlertDescription>
</Alert>
)}
@@ -1,7 +1,3 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { type Envelope, FieldType, type Passkey, type Recipient } from '@prisma/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import type {
@@ -13,6 +9,8 @@ import type {
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { trpc } from '@documenso/trpc/react';
import { type Envelope, FieldType, type Passkey, type Recipient } from '@prisma/client';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import type { DocumentSigningAuthDialogProps } from './document-signing-auth-dialog';
import { DocumentSigningAuthDialog } from './document-signing-auth-dialog';
@@ -24,10 +22,7 @@ type PasskeyData = {
isError: boolean;
};
type SigningAuthRecipient = Pick<
Recipient,
'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'
>;
type SigningAuthRecipient = Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'>;
export type DocumentSigningAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
@@ -87,12 +82,7 @@ export const DocumentSigningAuthProvider = ({
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
const [preferredPasskeyId, setPreferredPasskeyId] = useState<string | null>(null);
const {
documentAuthOption,
recipientAuthOption,
derivedRecipientAccessAuth,
derivedRecipientActionAuth,
} = useMemo(
const { documentAuthOption, recipientAuthOption, derivedRecipientAccessAuth, derivedRecipientActionAuth } = useMemo(
() =>
extractDocumentAuthMethods({
documentAuth: documentAuthOptions,
@@ -118,8 +108,9 @@ export const DocumentSigningAuthProvider = ({
isError: passkeyQuery.isError,
};
const [documentAuthDialogPayload, setDocumentAuthDialogPayload] =
useState<ExecuteActionAuthProcedureOptions | null>(null);
const [documentAuthDialogPayload, setDocumentAuthDialogPayload] = useState<ExecuteActionAuthProcedureOptions | null>(
null,
);
/**
* The pre calculated auth payload if the current user is authenticated correctly
@@ -139,10 +130,7 @@ export const DocumentSigningAuthProvider = ({
};
}
if (
derivedRecipientActionAuth.includes(DocumentAuth.ACCOUNT) &&
user?.email == recipient.email
) {
if (derivedRecipientActionAuth.includes(DocumentAuth.ACCOUNT) && user?.email === recipient.email) {
return {
type: DocumentAuth.ACCOUNT,
};
@@ -182,14 +170,11 @@ export const DocumentSigningAuthProvider = ({
}, [passkeyData.passkeys]);
const authMethodsRequiringLogin = derivedRecipientActionAuth?.filter(
(method) =>
method !== DocumentAuth.EXPLICIT_NONE && method !== DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
(method) => method !== DocumentAuth.EXPLICIT_NONE && method !== DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
);
const isAuthRedirectRequired = Boolean(
authMethodsRequiringLogin &&
authMethodsRequiringLogin.length > 0 &&
user?.email !== recipient.email,
authMethodsRequiringLogin && authMethodsRequiringLogin.length > 0 && user?.email !== recipient.email,
);
const refetchPasskeys = async () => {
@@ -1,30 +1,22 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { P, match } from 'ts-pattern';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import { AUTO_SIGNABLE_FIELD_TYPES } from '@documenso/lib/constants/autosign';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { Form } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { match, P } from 'ts-pattern';
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
@@ -166,15 +158,17 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Automatically sign fields</DialogTitle>
<DialogTitle>
<Trans>Automatically sign fields</Trans>
</DialogTitle>
</DialogHeader>
<div className="max-w-[50ch] text-muted-foreground">
<p>
<Trans>
When you sign a document, we can automatically fill in and sign the following fields
using information that has already been provided. You can also manually sign or remove
any automatically signed fields afterwards if you desire.
When you sign a document, we can automatically fill in and sign the following fields using information
that has already been provided. You can also manually sign or remove any automatically signed fields
afterwards if you desire.
</Trans>
</p>
@@ -1,10 +1,3 @@
import { useEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -22,6 +15,11 @@ import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useRevalidator } from 'react-router';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
@@ -60,9 +58,7 @@ export const DocumentSigningCheckboxField = ({
const [checkedValues, setCheckedValues] = useState(
values
?.map((item) =>
item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '',
)
?.map((item) => (item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : ''))
.filter(Boolean) || [],
);
@@ -70,12 +66,12 @@ export const DocumentSigningCheckboxField = ({
const checkboxValidationRule = parsedFieldMeta.validationRule;
const checkboxValidationLength = parsedFieldMeta.validationLength;
const validationSign = checkboxValidationSigns.find(
(sign) => sign.label === checkboxValidationRule,
);
const validationSign = checkboxValidationSigns.find((sign) => sign.label === checkboxValidationRule);
const isLengthConditionMet = useMemo(() => {
if (!validationSign) return true;
if (!validationSign) {
return true;
}
return (
(validationSign.value === '>=' && checkedValues.length >= (checkboxValidationLength || 0)) ||
(validationSign.value === '=' && checkedValues.length === (checkboxValidationLength || 0)) ||
@@ -86,10 +82,8 @@ export const DocumentSigningCheckboxField = ({
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { mutateAsync: removeSignedFieldWithToken, isPending: isRemoveSignedFieldWithTokenLoading } =
trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
const shouldAutoSignField =
@@ -180,27 +174,16 @@ export const DocumentSigningCheckboxField = ({
setCheckedValues(updatedValues);
};
const handleCheckboxOptionClick = async (item: {
id: number;
checked: boolean;
value: string;
}) => {
const handleCheckboxOptionClick = async (item: { id: number; checked: boolean; value: string }) => {
let updatedValues: string[] = [];
try {
const isChecked = checkedValues.includes(
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
);
const isChecked = checkedValues.includes(item.value.length > 0 ? item.value : `empty-value-${item.id}`);
if (!isChecked) {
updatedValues = [
...checkedValues,
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
];
updatedValues = [...checkedValues, item.value.length > 0 ? item.value : `empty-value-${item.id}`];
} else {
updatedValues = checkedValues.filter(
(v) => v !== item.value && v !== `empty-value-${item.id}`,
);
updatedValues = checkedValues.filter((v) => v !== item.value && v !== `empty-value-${item.id}`);
}
setCheckedValues(updatedValues);
@@ -252,21 +235,13 @@ export const DocumentSigningCheckboxField = ({
}
}, [checkedValues, isLengthConditionMet, field.inserted]);
const parsedCheckedValues = useMemo(
() => fromCheckboxValue(field.customText),
[field.customText],
);
const parsedCheckedValues = useMemo(() => fromCheckboxValue(field.customText), [field.customText]);
return (
<DocumentSigningFieldContainer
field={field}
onSign={onSign}
onRemove={onRemove}
type="Checkbox"
>
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Checkbox">
{isLoading && (
<div className="bg-background absolute inset-0 z-20 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-md bg-background">
<Loader className="h-5 w-5 animate-spin text-primary md:h-8 md:w-8" />
</div>
)}
@@ -280,9 +255,7 @@ export const DocumentSigningCheckboxField = ({
<div
className={cn(
'z-50 my-0.5 flex gap-1',
parsedFieldMeta.direction === 'horizontal'
? 'flex-row flex-wrap'
: 'flex-col gap-y-1',
parsedFieldMeta.direction === 'horizontal' ? 'flex-row flex-wrap' : 'flex-col gap-y-1',
)}
>
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
@@ -300,7 +273,7 @@ export const DocumentSigningCheckboxField = ({
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`checkbox-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
className="ml-1.5 font-normal text-foreground text-xs"
>
{item.value}
</Label>
@@ -334,7 +307,7 @@ export const DocumentSigningCheckboxField = ({
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`checkbox-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
className="ml-1.5 font-normal text-foreground text-xs"
>
{item.value}
</Label>
@@ -1,19 +1,7 @@
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import {
type TRecipientAccessAuth,
ZDocumentAccessAuthSchema,
} from '@documenso/lib/types/document-auth';
import { type TRecipientAccessAuth, ZDocumentAccessAuthSchema } from '@documenso/lib/types/document-auth';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { zEmail } from '@documenso/lib/utils/zod';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -24,15 +12,16 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
@@ -68,7 +57,7 @@ export type DocumentSigningCompleteDialogProps = {
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
email: zEmail('Invalid email address'),
accessAuthOptions: ZDocumentAccessAuthSchema.optional(),
});
@@ -76,7 +65,7 @@ type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
const ZDirectRecipientFormSchema = z.object({
name: z.string(),
email: z.string().email('Invalid email address'),
email: zEmail('Invalid email address'),
});
type TDirectRecipientFormSchema = z.infer<typeof ZDirectRecipientFormSchema>;
@@ -117,7 +106,7 @@ export const DocumentSigningCompleteDialog = ({
const recipientForm = useForm<TDirectRecipientFormSchema>({
resolver: zodResolver(ZDirectRecipientFormSchema),
defaultValues: {
values: {
name: recipientPayload?.name ?? '',
email: recipientPayload?.email ?? '',
},
@@ -157,6 +146,10 @@ export const DocumentSigningCompleteDialog = ({
}
recipientOverridePayload = recipientForm.getValues();
} else if (recipientPayload && recipientPayload.email && !recipient.email) {
// Form is hidden because we have an email (e.g. from embed context),
// but the DB recipient doesn't have one yet — send the override.
recipientOverridePayload = recipientPayload;
}
// Check if 2FA is required
@@ -166,9 +159,7 @@ export const DocumentSigningCompleteDialog = ({
}
const nextSigner =
allowDictateNextSigner && data.name && data.email
? { name: data.name, email: data.email }
: undefined;
allowDictateNextSigner && data.name && data.email ? { name: data.name, email: data.email } : undefined;
await onSignatureComplete(nextSigner, data.accessAuthOptions, recipientOverridePayload);
} catch (error) {
@@ -210,9 +201,7 @@ export const DocumentSigningCompleteDialog = ({
{match({ isComplete, role: recipient.role })
.with({ isComplete: false }, () => <Trans>Next Field</Trans>)
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
<Trans>Mark as viewed</Trans>
))
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => <Trans>Mark as viewed</Trans>)
.with({ isComplete: true }, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button>
@@ -253,31 +242,78 @@ export const DocumentSigningCompleteDialog = ({
</DialogHeader>
<div className="rounded-lg border border-border bg-muted/50 p-4 text-center">
<p className="text-sm font-medium text-muted-foreground">{documentTitle}</p>
<p className="font-medium text-muted-foreground text-sm">{documentTitle}</p>
</div>
{!showTwoFactorForm && (
<>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
{recipientPayload && !recipientPayload.email && (
<Form {...recipientForm}>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
{recipientPayload && !recipientPayload.email && (
<Form {...recipientForm}>
<div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={recipientForm.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Your Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder={t`Enter your name`}
disabled={isNameLocked || disableNameInput}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={recipientForm.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Your Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder={t`Enter your email`}
disabled={!!field.value && isEmailLocked}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</Form>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
{allowDictateNextSigner && defaultNextSigner && (
<div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={recipientForm.control}
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Your Name</Trans>
<Trans>Next Recipient Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder={t`Enter your name`}
disabled={isNameLocked || disableNameInput}
/>
<Input {...field} className="mt-2" placeholder={t`Enter the next signer's name`} />
</FormControl>
<FormMessage />
@@ -286,20 +322,19 @@ export const DocumentSigningCompleteDialog = ({
/>
<FormField
control={recipientForm.control}
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Your Email</Trans>
<Trans>Next Recipient Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder={t`Enter your email`}
disabled={!!field.value && isEmailLocked}
placeholder={t`Enter the next signer's email`}
/>
</FormControl>
<FormMessage />
@@ -308,89 +343,33 @@ export const DocumentSigningCompleteDialog = ({
/>
</div>
</div>
</Form>
)}
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
{allowDictateNextSigner && defaultNextSigner && (
<div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Next Recipient Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder={t`Enter the next signer's name`}
/>
</FormControl>
<DocumentSigningDisclosure />
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button
type="button"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Next Recipient Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder={t`Enter the next signer's email`}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
)}
<DocumentSigningDisclosure />
<DialogFooter className="mt-4">
<Button
type="button"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
disabled={!isComplete}
loading={form.formState.isSubmitting}
>
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button>
</DialogFooter>
</form>
</Form>
</fieldset>
</>
<Button type="submit" disabled={!isComplete} loading={form.formState.isSubmitting}>
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button>
</DialogFooter>
</form>
</Form>
</fieldset>
)}
{showTwoFactorForm && (
@@ -1,13 +1,4 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { convertToLocalSystemFormat, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@@ -21,6 +12,11 @@ import type {
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
@@ -49,10 +45,8 @@ export const DocumentSigningDateField = ({
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { mutateAsync: removeSignedFieldWithToken, isPending: isRemoveSignedFieldWithTokenLoading } =
trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
@@ -136,13 +130,13 @@ export const DocumentSigningDateField = ({
tooltipText={isDifferentTime ? tooltipText : undefined}
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-background">
<Loader className="h-5 w-5 animate-spin text-primary md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<p className="text-[clamp(0.425rem,25cqw,0.825rem)] text-foreground duration-200 group-hover:text-primary group-hover:text-recipient-green">
<Trans>Date</Trans>
</p>
)}
@@ -151,7 +145,7 @@ export const DocumentSigningDateField = ({
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-foreground w-full whitespace-nowrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
'w-full whitespace-nowrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] text-foreground duration-200',
{
'!text-center': parsedFieldMeta?.textAlign === 'center',
'!text-right': parsedFieldMeta?.textAlign === 'right',
@@ -1,32 +1,23 @@
import type { HTMLAttributes } from 'react';
import { cn } from '@documenso/ui/lib/utils';
import { Trans } from '@lingui/react/macro';
import type { HTMLAttributes } from 'react';
import { Link } from 'react-router';
import { cn } from '@documenso/ui/lib/utils';
export type DocumentSigningDisclosureProps = HTMLAttributes<HTMLParagraphElement>;
export const DocumentSigningDisclosure = ({
className,
...props
}: DocumentSigningDisclosureProps) => {
export const DocumentSigningDisclosure = ({ className, ...props }: DocumentSigningDisclosureProps) => {
return (
<p className={cn('text-muted-foreground text-xs', className)} {...props}>
<Trans>
By proceeding with your electronic signature, you acknowledge and consent that it will be
used to sign the given document and holds the same legal validity as a handwritten
signature. By completing the electronic signing process, you affirm your understanding and
acceptance of these conditions.
By proceeding with your electronic signature, you acknowledge and consent that it will be used to sign the given
document and holds the same legal validity as a handwritten signature. By completing the electronic signing
process, you affirm your understanding and acceptance of these conditions.
</Trans>
<span className="mt-2 block">
<Trans>
Read the full{' '}
<Link
className="text-documenso-700 underline"
to="/articles/signature-disclosure"
target="_blank"
>
<Link className="text-documenso-700 underline" to="/articles/signature-disclosure" target="_blank">
signature disclosure
</Link>
.
@@ -1,10 +1,3 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -16,14 +9,13 @@ import type {
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useRevalidator } from 'react-router';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
@@ -56,14 +48,11 @@ export const DocumentSigningDropdownField = ({
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { mutateAsync: removeSignedFieldWithToken, isPending: isRemoveSignedFieldWithTokenLoading } =
trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
const shouldAutoSignField =
(!field.inserted && localChoice) || (!field.inserted && isReadOnly && defaultValue);
const shouldAutoSignField = (!field.inserted && localChoice) || (!field.inserted && isReadOnly && defaultValue);
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
@@ -171,23 +160,18 @@ export const DocumentSigningDropdownField = ({
type="Dropdown"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-background">
<Loader className="h-5 w-5 animate-spin text-primary md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p className="group-hover:text-primary text-foreground flex flex-col items-center justify-center duration-200">
<p className="flex flex-col items-center justify-center text-foreground duration-200 group-hover:text-primary">
<Select value={localChoice} onValueChange={handleSelectItem}>
<SelectTrigger
className={cn(
'text-foreground z-10 h-full w-full border-none ring-0 focus:border-none focus:ring-0',
)}
className={cn('z-10 h-full w-full border-none text-foreground ring-0 focus:border-none focus:ring-0')}
>
<SelectValue
className="text-[clamp(0.425rem,25cqw,0.825rem)]"
placeholder={`${_(msg`Select`)}`}
/>
<SelectValue className="text-[clamp(0.425rem,25cqw,0.825rem)]" placeholder={`${_(msg`Select`)}`} />
</SelectTrigger>
<SelectContent className="w-full ring-0 focus:ring-0" position="popper">
{parsedFieldMeta?.values?.map((item, index) => (
@@ -201,9 +185,7 @@ export const DocumentSigningDropdownField = ({
)}
{field.inserted && (
<p className="text-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
<p className="text-[clamp(0.425rem,25cqw,0.825rem)] text-foreground duration-200">{field.customText}</p>
)}
</DocumentSigningFieldContainer>
</div>
@@ -1,8 +1,3 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -14,6 +9,10 @@ import type {
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useRevalidator } from 'react-router';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
@@ -30,11 +29,7 @@ export type DocumentSigningEmailFieldProps = {
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DocumentSigningEmailField = ({
field,
onSignField,
onUnsignField,
}: DocumentSigningEmailFieldProps) => {
export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }: DocumentSigningEmailFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
@@ -46,10 +41,8 @@ export const DocumentSigningEmailField = ({
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { mutateAsync: removeSignedFieldWithToken, isPending: isRemoveSignedFieldWithTokenLoading } =
trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
@@ -1,17 +1,15 @@
import React from 'react';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { TooltipArrow } from '@radix-ui/react-tooltip';
import { X } from 'lucide-react';
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type React from 'react';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
@@ -40,17 +38,7 @@ export type DocumentSigningFieldContainerProps = {
*/
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
onRemove?: (fieldType?: string) => Promise<void> | void;
type?:
| 'Date'
| 'Initials'
| 'Email'
| 'Name'
| 'Signature'
| 'Text'
| 'Radio'
| 'Dropdown'
| 'Number'
| 'Checkbox';
type?: 'Date' | 'Initials' | 'Email' | 'Name' | 'Signature' | 'Text' | 'Radio' | 'Dropdown' | 'Number' | 'Checkbox';
tooltipText?: string | null;
};
@@ -64,8 +52,7 @@ export const DocumentSigningFieldContainer = ({
type,
tooltipText,
}: DocumentSigningFieldContainerProps) => {
const { executeActionAuthProcedure, isAuthRedirectRequired } =
useRequiredDocumentSigningAuthContext();
const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentSigningAuthContext();
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
const readOnlyField = parsedFieldMeta?.readOnly || false;
@@ -130,69 +117,58 @@ export const DocumentSigningFieldContainer = ({
};
return (
<div className={cn('[container-type:size]')}>
<FieldRootContainer
color={
field.fieldMeta?.readOnly ? RECIPIENT_COLOR_STYLES.readOnly : RECIPIENT_COLOR_STYLES.green
}
field={field}
>
{!field.inserted && !loading && !readOnlyField && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
onClick={async () => handleInsertField()}
/>
)}
<FieldRootContainer color={getRecipientColorStyles(field.fieldMeta?.readOnly ? 'readOnly' : 0)} field={field}>
{!field.inserted && !loading && !readOnlyField && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
onClick={async () => handleInsertField()}
/>
)}
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
onClick={() => void onClearCheckBoxValues(type)}
>
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<X className="h-4 w-4" />
</span>
</button>
)}
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
onClick={() => void onClearCheckBoxValues(type)}
>
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<X className="h-4 w-4" />
</span>
</button>
)}
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
</TooltipTrigger>
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
</TooltipTrigger>
<TooltipContent
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
sideOffset={2}
>
{tooltipText && <p>{tooltipText}</p>}
<TooltipContent className="border-0 bg-orange-300 fill-orange-300 text-orange-900" sideOffset={2}>
{tooltipText && <p>{tooltipText}</p>}
<Trans>Remove</Trans>
<TooltipArrow />
</TooltipContent>
</Tooltip>
)}
<Trans>Remove</Trans>
<TooltipArrow />
</TooltipContent>
</Tooltip>
)}
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
{
'bg-foreground/5 border-border border': !field.inserted,
},
{
'bg-documenso-200 border-primary border': field.inserted,
},
)}
>
{field.fieldMeta.label}
</div>
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) && field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 right-0 left-0 rounded-md p-2 text-center text-gray-700 text-xs',
{
'border border-border bg-foreground/5': !field.inserted,
},
{
'border border-primary bg-documenso-200': field.inserted,
},
)}
>
{field.fieldMeta.label}
</div>
)}
{children}
</FieldRootContainer>
</div>
{children}
</FieldRootContainer>
);
};
@@ -1,18 +1,17 @@
import { Loader } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Loader } from 'lucide-react';
export const DocumentSigningFieldsLoader = () => {
return (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-background">
<Loader className="h-5 w-5 animate-spin text-primary md:h-8 md:w-8" />
</div>
);
};
export const DocumentSigningFieldsUninserted = ({ children }: { children: React.ReactNode }) => {
return (
<p className="text-foreground group-hover:text-recipient-green whitespace-pre-wrap text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<p className="whitespace-pre-wrap text-[clamp(0.425rem,25cqw,0.825rem)] text-foreground duration-200 group-hover:text-recipient-green">
{children}
</p>
);
@@ -29,15 +28,12 @@ type DocumentSigningFieldsInsertedProps = {
textAlign?: 'left' | 'center' | 'right';
};
export const DocumentSigningFieldsInserted = ({
children,
textAlign = 'left',
}: DocumentSigningFieldsInsertedProps) => {
export const DocumentSigningFieldsInserted = ({ children, textAlign = 'left' }: DocumentSigningFieldsInsertedProps) => {
return (
<div className="flex h-full w-full items-center overflow-hidden">
<p
className={cn(
'text-foreground w-full whitespace-pre-wrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
'w-full whitespace-pre-wrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] text-foreground duration-200',
{
'!text-center': textAlign === 'center',
'!text-right': textAlign === 'right',
@@ -1,12 +1,3 @@
import { useId, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Field, type Recipient, RecipientRole } from '@prisma/client';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
@@ -20,11 +11,15 @@ import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Field, type Recipient, RecipientRole } from '@prisma/client';
import { useId, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import {
AssistantConfirmationDialog,
type NextSigner,
} from '../../dialogs/assistant-confirmation-dialog';
import { AssistantConfirmationDialog, type NextSigner } from '../../dialogs/assistant-confirmation-dialog';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
@@ -74,10 +69,7 @@ export const DocumentSigningForm = ({
},
});
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
[fields],
);
const fieldsRequiringValidation = useMemo(() => fields.filter(isFieldUnsignedAndRequired), [fields]);
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
@@ -130,133 +122,108 @@ export const DocumentSigningForm = ({
<div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
<div className="flex flex-1 flex-col">
{recipient.role === RecipientRole.VIEWER ? (
<>
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4" />
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4" />
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={localFieldsValidated}
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
completeDocument({ nextSigner, accessAuthOptions })
}
recipient={recipient}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</div>
</div>
</>
) : recipient.role === RecipientRole.ASSISTANT ? (
<>
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
<fieldset className="rounded-2xl border border-border bg-white p-3 dark:bg-background">
<Controller
name="selectedSignerId"
control={assistantForm.control}
rules={{ required: 'Please select a signer' }}
render={({ field }) => (
<RadioGroup
className="gap-0 space-y-3 shadow-none"
value={field.value?.toString()}
onValueChange={(value) => {
field.onChange(value);
setSelectedSignerId?.(Number(value));
}}
>
{allRecipients
.filter((r) => r.fields.length > 0)
.map((r) => (
<div
key={`${assistantSignersId}-${r.id}`}
className="relative flex flex-col gap-4 rounded-lg border border-border bg-widget p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RadioGroupItem
id={`${assistantSignersId}-${r.id}`}
value={r.id.toString()}
className="after:absolute after:inset-0"
/>
<div className="grid grow gap-1">
<Label
className="inline-flex items-start"
htmlFor={`${assistantSignersId}-${r.id}`}
>
{r.name}
{r.id === recipient.id && (
<span className="ml-2 text-muted-foreground">
{_(msg`(You)`)}
</span>
)}
</Label>
<p className="text-xs text-muted-foreground">{r.email}</p>
</div>
</div>
<div className="text-xs leading-[inherit] text-muted-foreground">
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
</div>
</div>
</div>
))}
</RadioGroup>
)}
/>
</fieldset>
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button
type="submit"
className="w-full"
size="lg"
loading={isAssistantSubmitting}
>
<Trans>Continue</Trans>
</Button>
</div>
<AssistantConfirmationDialog
hasUninsertedFields={uninsertedFields.length > 0}
isOpen={isConfirmationDialogOpen}
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={localFieldsValidated}
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
completeDocument({ nextSigner, accessAuthOptions })
}
recipient={recipient}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
/>
</form>
</>
</div>
</div>
) : recipient.role === RecipientRole.ASSISTANT ? (
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
<fieldset className="rounded-2xl border border-border bg-white p-3 dark:bg-background">
<Controller
name="selectedSignerId"
control={assistantForm.control}
rules={{ required: 'Please select a signer' }}
render={({ field }) => (
<RadioGroup
className="gap-0 space-y-3 shadow-none"
value={field.value?.toString()}
onValueChange={(value) => {
field.onChange(value);
setSelectedSignerId?.(Number(value));
}}
>
{allRecipients
.filter((r) => r.fields.length > 0)
.map((r) => (
<div
key={`${assistantSignersId}-${r.id}`}
className="relative flex flex-col gap-4 rounded-lg border border-border bg-widget p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RadioGroupItem
id={`${assistantSignersId}-${r.id}`}
value={r.id.toString()}
className="after:absolute after:inset-0"
/>
<div className="grid grow gap-1">
<Label className="inline-flex items-start" htmlFor={`${assistantSignersId}-${r.id}`}>
{r.name}
{r.id === recipient.id && (
<span className="ml-2 text-muted-foreground">{_(msg`(You)`)}</span>
)}
</Label>
<p className="text-muted-foreground text-xs">{r.email}</p>
</div>
</div>
<div className="text-muted-foreground text-xs leading-[inherit]">
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
</div>
</div>
</div>
))}
</RadioGroup>
)}
/>
</fieldset>
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button type="submit" className="w-full" size="lg" loading={isAssistantSubmitting}>
<Trans>Continue</Trans>
</Button>
</div>
<AssistantConfirmationDialog
hasUninsertedFields={uninsertedFields.length > 0}
isOpen={isConfirmationDialogOpen}
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting}
allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined}
/>
</form>
) : (
<>
<fieldset
disabled={isSubmitting}
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
>
<fieldset disabled={isSubmitting} className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">
@@ -318,13 +285,9 @@ export const DocumentSigningForm = ({
})
}
recipient={recipient}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
/>
</div>
@@ -1,8 +1,3 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -15,6 +10,10 @@ import type {
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useRevalidator } from 'react-router';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
@@ -48,10 +47,8 @@ export const DocumentSigningInitialsField = ({
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { mutateAsync: removeSignedFieldWithToken, isPending: isRemoveSignedFieldWithTokenLoading } =
trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
@@ -124,12 +121,7 @@ export const DocumentSigningInitialsField = ({
};
return (
<DocumentSigningFieldContainer
field={field}
onSign={onSign}
onRemove={onRemove}
type="Initials"
>
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Initials">
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
@@ -1,13 +1,12 @@
import { useEffect, useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { Plural, Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useEffect, useState } from 'react';
import { match } from 'ts-pattern';
import { Button } from '@documenso/ui/primitives/button';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { BrandingLogo } from '../branding-logo';
@@ -20,8 +19,7 @@ export const DocumentSigningMobileWidget = () => {
const { hidePoweredBy = true } = useEmbedSigningContext() || {};
const { recipientFieldsRemaining, recipient, requiredRecipientFields } =
useRequiredEnvelopeSigningContext();
const { recipientFieldsRemaining, recipient, requiredRecipientFields } = useRequiredEnvelopeSigningContext();
/**
* Pre open the widget for assistants to let them know it's there.
@@ -33,7 +31,7 @@ export const DocumentSigningMobileWidget = () => {
}, []);
return (
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 flex justify-center px-2 pb-2 sm:px-4 sm:pb-6">
<div className="pointer-events-none fixed right-0 bottom-0 left-0 z-50 flex justify-center px-2 pb-2 sm:px-4 sm:pb-6">
<div className="pointer-events-auto w-full max-w-[760px]">
<div className="overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
{/* Main Header Bar */}
@@ -56,7 +54,7 @@ export const DocumentSigningMobileWidget = () => {
)}
<div>
<h2 className="text-lg font-semibold text-foreground">
<h2 className="font-semibold text-foreground text-lg">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
@@ -65,21 +63,13 @@ export const DocumentSigningMobileWidget = () => {
.otherwise(() => null)}
</h2>
<p className="-mt-0.5 text-sm text-muted-foreground">
<p className="-mt-0.5 text-muted-foreground text-sm">
{recipientFieldsRemaining.length === 0 ? (
match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<Trans>Please mark as viewed to complete</Trans>
))
.with(RecipientRole.SIGNER, () => (
<Trans>Please complete the document once reviewed</Trans>
))
.with(RecipientRole.APPROVER, () => (
<Trans>Please complete the document once reviewed</Trans>
))
.with(RecipientRole.ASSISTANT, () => (
<Trans>Please complete the document once reviewed</Trans>
))
.with(RecipientRole.VIEWER, () => <Trans>Please mark as viewed to complete</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Please complete the document once reviewed</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Please complete the document once reviewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Please complete the document once reviewed</Trans>)
.otherwise(() => null)
) : (
<Plural
@@ -99,30 +89,31 @@ export const DocumentSigningMobileWidget = () => {
</div>
{/* Progress Bar */}
{recipient.role !== RecipientRole.VIEWER &&
recipient.role !== RecipientRole.ASSISTANT && (
<div className="px-4 pb-3">
<div className="relative h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-signing-mobile-widget-progress-bar"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
/>
</div>
{recipient.role !== RecipientRole.VIEWER && recipient.role !== RecipientRole.ASSISTANT && (
<div className="px-4 pb-3">
<div className="relative h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-signing-mobile-widget-progress-bar"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
/>
</div>
)}
</div>
)}
{/* Expandable Content */}
{isExpanded && (
<div className="border-t border-border p-4 duration-200 animate-in slide-in-from-bottom-2">
<div className="slide-in-from-bottom-2 animate-in border-border border-t p-4 duration-200">
<EnvelopeSignerForm />
{!hidePoweredBy && (
<div className="mt-2 inline-block rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:hidden">
<span>Powered by</span>
<div className="mt-2 inline-block rounded bg-primary px-2 py-1 font-medium text-primary-foreground text-xs opacity-60 hover:opacity-100 lg:hidden">
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -1,10 +1,3 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -20,6 +13,11 @@ import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { useRevalidator } from 'react-router';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
@@ -37,17 +35,12 @@ export type DocumentSigningNameFieldProps = {
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DocumentSigningNameField = ({
field,
onSignField,
onUnsignField,
}: DocumentSigningNameFieldProps) => {
export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }: DocumentSigningNameFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { fullName: providedFullName, setFullName: setProvidedFullName } =
useRequiredDocumentSigningContext();
const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredDocumentSigningContext();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -56,10 +49,8 @@ export const DocumentSigningNameField = ({
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { mutateAsync: removeSignedFieldWithToken, isPending: isRemoveSignedFieldWithTokenLoading } =
trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
@@ -162,13 +153,7 @@ export const DocumentSigningNameField = ({
};
return (
<DocumentSigningFieldContainer
field={field}
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Name"
>
<DocumentSigningFieldContainer field={field} onPreSign={onPreSign} onSign={onSign} onRemove={onRemove} type="Name">
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
@@ -221,12 +206,7 @@ export const DocumentSigningNameField = ({
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
className="flex-1"
disabled={!localFullName}
onClick={() => onDialogSignClick()}
>
<Button type="button" className="flex-1" disabled={!localFullName} onClick={() => onDialogSignClick()}>
<Trans>Sign</Trans>
</Button>
</div>
@@ -1,10 +1,3 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useRevalidator } from 'react-router';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@@ -21,6 +14,11 @@ import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useRevalidator } from 'react-router';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
@@ -45,11 +43,7 @@ export type DocumentSigningNumberFieldProps = {
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DocumentSigningNumberField = ({
field,
onSignField,
onUnsignField,
}: DocumentSigningNumberFieldProps) => {
export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }: DocumentSigningNumberFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
@@ -62,9 +56,7 @@ export const DocumentSigningNumberField = ({
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const defaultValue = parsedFieldMeta?.value;
const [localNumber, setLocalNumber] = useState(() =>
parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '',
);
const [localNumber, setLocalNumber] = useState(() => (parsedFieldMeta?.value ? String(parsedFieldMeta.value) : ''));
const initialErrors: ValidationErrors = {
isNumber: [],
@@ -81,10 +73,8 @@ export const DocumentSigningNumberField = ({
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { mutateAsync: removeSignedFieldWithToken, isPending: isRemoveSignedFieldWithTokenLoading } =
trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
@@ -231,9 +221,7 @@ export const DocumentSigningNumberField = ({
if (parsedFieldMeta?.label) {
fieldDisplayName =
parsedFieldMeta.label.length > 20
? parsedFieldMeta.label.substring(0, 20) + '...'
: parsedFieldMeta.label;
parsedFieldMeta.label.length > 20 ? parsedFieldMeta.label.substring(0, 20) + '...' : parsedFieldMeta.label;
}
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
@@ -248,9 +236,7 @@ export const DocumentSigningNumberField = ({
>
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
<DocumentSigningFieldsUninserted>{fieldDisplayName}</DocumentSigningFieldsUninserted>
)}
{!field.inserted && <DocumentSigningFieldsUninserted>{fieldDisplayName}</DocumentSigningFieldsUninserted>}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
@@ -260,9 +246,7 @@ export const DocumentSigningNumberField = ({
<Dialog open={showNumberModal} onOpenChange={setShowNumberModal}>
<DialogContent>
<DialogTitle>
{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Number</Trans>}
</DialogTitle>
<DialogTitle>{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Number</Trans>}</DialogTitle>
<div>
<Input
@@ -280,27 +264,27 @@ export const DocumentSigningNumberField = ({
{userInputHasErrors && (
<div>
{errors.isNumber?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
<p key={index} className="mt-2 text-red-500 text-sm">
{error}
</p>
))}
{errors.required?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
<p key={index} className="mt-2 text-red-500 text-sm">
{error}
</p>
))}
{errors.minValue?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
<p key={index} className="mt-2 text-red-500 text-sm">
{error}
</p>
))}
{errors.maxValue?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
<p key={index} className="mt-2 text-red-500 text-sm">
{error}
</p>
))}
{errors.numberFormat?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
<p key={index} className="mt-2 text-red-500 text-sm">
{error}
</p>
))}
@@ -1,12 +1,3 @@
import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useNavigate } from 'react-router';
import { P, match } from 'ts-pattern';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
@@ -22,6 +13,7 @@ import {
} from '@documenso/lib/types/field-meta';
import type { CompletedField } from '@documenso/lib/types/fields';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
@@ -30,7 +22,13 @@ import { DocumentReadOnlyFields } from '@documenso/ui/components/document/docume
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { match, P } from 'ts-pattern';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
@@ -46,6 +44,7 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
@@ -93,10 +92,7 @@ export const DocumentSigningPageViewV1 = ({
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = isPending || isSuccess;
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
[fields],
);
const fieldsRequiringValidation = useMemo(() => fields.filter(isFieldUnsignedAndRequired), [fields]);
const fieldsValidated = () => {
validateFieldsInserted(fieldsRequiringValidation);
@@ -139,8 +135,7 @@ export const DocumentSigningPageViewV1 = ({
}
const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId);
const targetSigner =
recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
const nextRecipient = useMemo(() => {
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
@@ -149,10 +144,18 @@ export const DocumentSigningPageViewV1 = ({
const sortedRecipients = allRecipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
});
@@ -162,24 +165,21 @@ export const DocumentSigningPageViewV1 = ({
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
const highestPageNumber = Math.max(...fields.map((field) => field.page));
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
const hasPendingFields = pendingFields.length > 0;
return (
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
{document.team.teamGlobalSettings.brandingEnabled &&
document.team.teamGlobalSettings.brandingLogo && (
<img
src={`/api/branding/logo/team/${document.teamId}`}
alt={`${document.team.name}'s Logo`}
className="mb-4 h-12 w-12 md:mb-2"
/>
)}
{document.team.teamGlobalSettings.brandingEnabled && document.team.teamGlobalSettings.brandingLogo && (
<img
src={`/api/branding/logo/team/${document.teamId}`}
alt={`${document.team.name}'s Logo`}
className="mb-4 h-12 w-12 md:mb-2"
/>
)}
<h1
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
className="block max-w-[20rem] truncate font-semibold text-2xl sm:mt-4 md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
@@ -262,10 +262,7 @@ export const DocumentSigningPageViewV1 = ({
</div>
<div className="flex items-center gap-x-4">
<DocumentSigningAttachmentsPopover
envelopeId={document.envelopeId}
token={recipient.token}
/>
<DocumentSigningAttachmentsPopover envelopeId={document.envelopeId} token={recipient.token} />
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
</div>
</div>
@@ -275,10 +272,16 @@ export const DocumentSigningPageViewV1 = ({
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewerLazy
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={recipient.token}
version="signed"
key={document.envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: document.envelopeId,
envelopeItemId: document.envelopeItems[0]?.id,
documentDataId: document.envelopeItems[0]?.documentData.id,
version: 'current',
token: recipient.token,
presignToken: undefined,
})}
scrollParentRef="window"
/>
</CardContent>
</Card>
@@ -286,12 +289,12 @@ export const DocumentSigningPageViewV1 = ({
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="group/document-widget fixed bottom-6 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-4 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
className="group/document-widget fixed bottom-6 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-4 md:sticky md:top-4 md:bottom-[unset] md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="flex w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:py-6">
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-xl font-semibold text-foreground md:text-2xl">
<h3 className="font-semibold text-foreground text-xl md:text-2xl">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
@@ -315,17 +318,11 @@ export const DocumentSigningPageViewV1 = ({
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) =>
completeDocument({ nextSigner })
}
onSignatureComplete={async (nextSigner) => completeDocument({ nextSigner })}
recipient={recipient}
allowDictateNextSigner={
nextRecipient && documentMeta?.allowDictateNextSigner
}
allowDictateNextSigner={nextRecipient && documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
/>
</div>
@@ -352,24 +349,16 @@ export const DocumentSigningPageViewV1 = ({
</div>
<div className="hidden group-data-[expanded]/document-widget:block md:block">
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-2 text-muted-foreground text-sm">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<Trans>Please mark as viewed to complete.</Trans>
))
.with(RecipientRole.SIGNER, () => (
<Trans>Please review the document before signing.</Trans>
))
.with(RecipientRole.APPROVER, () => (
<Trans>Please review the document before approving.</Trans>
))
.with(RecipientRole.ASSISTANT, () => (
<Trans>Complete the fields for the following signers.</Trans>
))
.with(RecipientRole.VIEWER, () => <Trans>Please mark as viewed to complete.</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Please review the document before signing.</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Please review the document before approving.</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete the fields for the following signers.</Trans>)
.otherwise(() => null)}
</p>
<hr className="mb-8 mt-4 border-border" />
<hr className="mt-4 mb-8 border-border" />
</div>
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
@@ -400,15 +389,9 @@ export const DocumentSigningPageViewV1 = ({
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
)}
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields
.filter(
(field) =>
recipient.role !== RecipientRole.ASSISTANT ||
field.recipientId === selectedSigner?.id,
)
.filter((field) => recipient.role !== RecipientRole.ASSISTANT || field.recipientId === selectedSigner?.id)
.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
@@ -420,12 +403,8 @@ export const DocumentSigningPageViewV1 = ({
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => (
<DocumentSigningInitialsField key={field.id} field={field} />
))
.with(FieldType.NAME, () => (
<DocumentSigningNameField key={field.id} field={field} />
))
.with(FieldType.INITIALS, () => <DocumentSigningInitialsField key={field.id} field={field} />)
.with(FieldType.NAME, () => <DocumentSigningNameField key={field.id} field={field} />)
.with(FieldType.DATE, () => (
<DocumentSigningDateField
key={field.id}
@@ -434,9 +413,7 @@ export const DocumentSigningPageViewV1 = ({
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/>
))
.with(FieldType.EMAIL, () => (
<DocumentSigningEmailField key={field.id} field={field} />
))
.with(FieldType.EMAIL, () => <DocumentSigningEmailField key={field.id} field={field} />)
.with(FieldType.TEXT, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
@@ -1,17 +1,23 @@
import { lazy, useMemo } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon, PaperclipIcon } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import {
ArrowLeftIcon,
BanIcon,
DownloadCloudIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PaperclipIcon,
} from 'lucide-react';
import { useMemo, useRef, useState } from 'react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { SignFieldCheckboxDialog } from '~/components/dialogs/sign-field-checkbox-dialog';
@@ -23,6 +29,8 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { EnvelopeSignerPageRenderer } from '~/components/general/envelope-signing/envelope-signer-page-renderer';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { BrandingLogo } from '../branding-logo';
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
@@ -33,13 +41,11 @@ import { DocumentSigningMobileWidget } from './document-signing-mobile-widget';
import { DocumentSigningRejectDialog } from './document-signing-reject-dialog';
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
const EnvelopeSignerPageRenderer = lazy(
async () => import('~/components/general/envelope-signing/envelope-signer-page-renderer'),
);
export const DocumentSigningPageViewV2 = () => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const {
isDirectTemplate,
envelope,
@@ -57,6 +63,9 @@ export const DocumentSigningPageViewV2 = () => {
onDocumentRejected,
} = useEmbedSigningContext() || {};
const { t } = useLingui();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
/**
* The total remaining fields remaining for the current recipient or selected assistant recipient.
*
@@ -86,120 +95,147 @@ export const DocumentSigningPageViewV2 = () => {
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div className="embed--DocumentWidgetContainer hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4 lg:flex">
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.otherwise(() => null)}
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Plural
value={recipientFieldsRemaining.length}
one="1 Field Remaining"
other="# Fields Remaining"
/>
</span>
</h3>
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
/>
</div>
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
<EnvelopeSignerForm />
</div>
</div>
<Separator className="my-6" />
{/* Quick Actions. */}
{!isDirectTemplate && (
<div className="embed--Actions space-y-3 px-4">
<h4 className="text-sm font-semibold text-foreground">
<Trans>Actions</Trans>
</h4>
<DocumentSigningAttachmentsPopover
envelopeId={envelope.id}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<PaperclipIcon className="mr-2 h-4 w-4" />
<Trans>Attachments</Trans>
</Button>
}
/>
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</Button>
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
onRejected={
onDocumentRejected &&
((reason) =>
onDocumentRejected({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
envelopeId: envelope.id,
recipientId: recipient.id,
reason,
}))
}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start hover:text-destructive"
>
<BanIcon className="mr-2 h-4 w-4" />
<Trans>Reject Document</Trans>
</Button>
}
/>
)}
<div
className={cn(
'embed--DocumentWidgetContainer hidden flex-shrink-0 flex-col border-border border-r bg-background transition-[width] duration-300 lg:flex',
isSidebarCollapsed ? 'w-12' : 'w-80',
)}
>
{isSidebarCollapsed && (
<div className="flex justify-center pt-4">
<Button
variant="ghost"
className="h-7 w-7 p-0"
aria-label={t`Expand sidebar`}
onClick={() => setIsSidebarCollapsed(false)}
>
<PanelLeftOpenIcon className="h-4 w-4" />
</Button>
</div>
)}
<div className="embed--DocumentWidgetFooter mt-auto">
{/* Footer of left sidebar. */}
{!isEmbed && (
<div className="px-4">
<Button asChild variant="ghost" className="w-full justify-start">
<Link to="/">
<ArrowLeftIcon className="mr-2 h-4 w-4" />
<Trans>Return</Trans>
</Link>
</Button>
<div className={cn('flex flex-1 flex-col overflow-hidden py-4', isSidebarCollapsed && 'invisible w-0')}>
<div className="px-4">
<h3 className="flex items-end justify-between font-semibold text-foreground text-sm">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.otherwise(() => null)}
<div className="ml-2 flex items-center gap-1">
<span className="rounded border bg-muted/50 px-2 py-0.5 text-muted-foreground text-xs">
<Plural
value={recipientFieldsRemaining.length}
one="1 Field Remaining"
other="# Fields Remaining"
/>
</span>
<Button
variant="ghost"
className="h-7 w-7 p-0"
aria-label={t`Collapse sidebar`}
onClick={() => setIsSidebarCollapsed(true)}
>
<PanelLeftCloseIcon className="h-4 w-4" />
</Button>
</div>
</h3>
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
/>
</div>
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
<EnvelopeSignerForm />
</div>
</div>
<Separator className="my-6" />
{/* Quick Actions. */}
{!isDirectTemplate && (
<div className="embed--Actions space-y-3 px-4">
<h4 className="font-semibold text-foreground text-sm">
<Trans>Actions</Trans>
</h4>
<DocumentSigningAttachmentsPopover
envelopeId={envelope.id}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<PaperclipIcon className="mr-2 h-4 w-4" />
<Trans>Attachments</Trans>
</Button>
}
/>
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</Button>
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
onRejected={
onDocumentRejected &&
((reason) =>
onDocumentRejected({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
envelopeId: envelope.id,
recipientId: recipient.id,
reason,
}))
}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start hover:text-destructive">
<BanIcon className="mr-2 h-4 w-4" />
<Trans>Reject Document</Trans>
</Button>
}
/>
)}
</div>
)}
<div className="embed--DocumentWidgetFooter mt-auto">
{/* Footer of left sidebar. */}
{!isEmbed && (
<div className="px-4">
<Button asChild variant="ghost" className="w-full justify-start">
<Link to="/">
<ArrowLeftIcon className="mr-2 h-4 w-4" />
<Trans>Return</Trans>
</Link>
</Button>
</div>
)}
</div>
</div>
</div>
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
<div className="embed--DocumentContainer min-w-0 flex-1 overflow-y-auto" ref={scrollableContainerRef}>
<div className="flex flex-col">
{/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && (
@@ -213,9 +249,7 @@ export const DocumentSigningPageViewV2 = () => {
<Plural
one="1 Field"
other="# Fields"
value={
remainingFields.filter((field) => field.envelopeItemId === doc.id).length
}
value={remainingFields.filter((field) => field.envelopeItemId === doc.id).length}
/>
}
isSelected={currentEnvelopeItem?.id === doc.id}
@@ -228,15 +262,16 @@ export const DocumentSigningPageViewV2 = () => {
{/* Document View */}
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
{currentEnvelopeItem ? (
<PDFViewerKonvaLazy
renderer="signing"
<EnvelopePdfViewer
key={currentEnvelopeItem.id}
customPageRenderer={EnvelopeSignerPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<p className="text-sm text-foreground">
<Trans>No documents found</Trans>
<p className="text-foreground text-sm">
<Trans>No document selected</Trans>
</p>
</div>
)}
@@ -250,9 +285,12 @@ export const DocumentSigningPageViewV2 = () => {
<a
href="https://documenso.com"
target="_blank"
className="fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:block"
className="fixed right-0 bottom-0 z-40 hidden cursor-pointer rounded-tl bg-primary px-2 py-1 font-medium text-primary-foreground text-xs opacity-60 hover:opacity-100 lg:block"
rel="noopener"
>
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</a>
)}
@@ -1,6 +1,5 @@
import { createContext, useContext, useState } from 'react';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { createContext, useContext, useState } from 'react';
export type DocumentSigningContextValue = {
fullName: string;
@@ -1,9 +1,3 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -17,6 +11,10 @@ import type {
import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useEffect, useState } from 'react';
import { useRevalidator } from 'react-router';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
@@ -29,11 +27,7 @@ export type DocumentSigningRadioFieldProps = {
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DocumentSigningRadioField = ({
field,
onSignField,
onUnsignField,
}: DocumentSigningRadioFieldProps) => {
export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }: DocumentSigningRadioFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
@@ -56,10 +50,8 @@ export const DocumentSigningRadioField = ({
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { mutateAsync: removeSignedFieldWithToken, isPending: isRemoveSignedFieldWithTokenLoading } =
trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
const shouldAutoSignField =
@@ -154,10 +146,7 @@ export const DocumentSigningRadioField = ({
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
<RadioGroup
onValueChange={(value) => handleSelectItem(value)}
className="z-10 my-0.5 gap-y-1"
>
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10 my-0.5 gap-y-1">
{values?.map((item, index) => (
<div key={index} className="flex items-center">
<RadioGroupItem
@@ -168,10 +157,7 @@ export const DocumentSigningRadioField = ({
disabled={isReadOnly}
/>
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`option-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
<Label htmlFor={`option-${field.id}-${item.id}`} className="ml-1.5 font-normal text-foreground text-xs">
{item.value}
</Label>
)}
@@ -192,10 +178,7 @@ export const DocumentSigningRadioField = ({
disabled={isReadOnly}
/>
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`option-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
<Label htmlFor={`option-${field.id}-${item.id}`} className="ml-1.5 font-normal text-foreground text-xs">
{item.value}
</Label>
)}
@@ -1,8 +1,7 @@
import { type PropsWithChildren, createContext, useContext } from 'react';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import type { Recipient } from '@prisma/client';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { createContext, type PropsWithChildren, useContext } from 'react';
export interface DocumentSigningRecipientContextValue {
/**
@@ -10,10 +9,7 @@ export interface DocumentSigningRecipientContextValue {
* In regular mode, this is the actual signer.
* In assistant mode, this is the recipient who is helping fill out the document.
*/
recipient: Pick<
Recipient | RecipientWithFields,
'name' | 'email' | 'token' | 'role' | 'authOptions'
>;
recipient: Pick<Recipient | RecipientWithFields, 'name' | 'email' | 'token' | 'role' | 'authOptions'>;
/**
* Only present in assistant mode.
@@ -27,15 +23,10 @@ export interface DocumentSigningRecipientContextValue {
isAssistantMode: boolean;
}
const DocumentSigningRecipientContext = createContext<DocumentSigningRecipientContextValue | null>(
null,
);
const DocumentSigningRecipientContext = createContext<DocumentSigningRecipientContextValue | null>(null);
export interface DocumentSigningRecipientProviderProps extends PropsWithChildren {
recipient: Pick<
Recipient | RecipientWithFields,
'name' | 'email' | 'token' | 'role' | 'authOptions'
>;
recipient: Pick<Recipient | RecipientWithFields, 'name' | 'email' | 'token' | 'role' | 'authOptions'>;
targetSigner?: RecipientWithFields | null;
}
@@ -1,13 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useSearchParams } from 'react-router';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -19,15 +9,16 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
const ZRejectDocumentFormSchema = z.object({
reason: z.string().max(500, msg`Reason must be less than 500 characters`),
@@ -55,8 +46,7 @@ export function DocumentSigningRejectDialog({
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: rejectDocumentWithToken } =
trpc.recipient.rejectDocumentWithToken.useMutation();
const { mutateAsync: rejectDocumentWithToken } = trpc.recipient.rejectDocumentWithToken.useMutation();
const form = useForm<TRejectDocumentFormSchema>({
resolver: zodResolver(ZRejectDocumentFormSchema),
@@ -125,9 +115,7 @@ export function DocumentSigningRejectDialog({
</DialogTitle>
<DialogDescription>
<Trans>
Are you sure you want to reject this document? This action cannot be undone.
</Trans>
<Trans>Are you sure you want to reject this document? This action cannot be undone.</Trans>
</DialogDescription>
</DialogHeader>
@@ -1,11 +1,3 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -19,6 +11,12 @@ import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator } from 'react-router';
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
@@ -67,10 +65,8 @@ export const DocumentSigningSignatureField = ({
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { mutateAsync: removeSignedFieldWithToken, isPending: isRemoveSignedFieldWithTokenLoading } =
trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { signature } = field;
@@ -211,10 +207,7 @@ export const DocumentSigningSignatureField = ({
let size = 2;
text.style.fontSize = `${size}rem`;
while (
(text.scrollWidth > container.clientWidth || text.scrollHeight > container.clientHeight) &&
size > 0.8
) {
while ((text.scrollWidth > container.clientWidth || text.scrollHeight > container.clientHeight) && size > 0.8) {
size -= 0.1;
text.style.fontSize = `${size}rem`;
}
@@ -245,7 +238,7 @@ export const DocumentSigningSignatureField = ({
)}
{state === 'empty' && (
<p className="font-signature text-[clamp(0.575rem,25cqw,1.2rem)] text-xl text-muted-foreground duration-200 group-hover:text-primary group-hover:text-recipient-green">
<p className="font-signature text-[clamp(0.575rem,25cqw,1.2rem)] text-muted-foreground text-xl duration-200 group-hover:text-primary group-hover:text-recipient-green">
<Trans>Signature</Trans>
</p>
)}
@@ -262,7 +255,7 @@ export const DocumentSigningSignatureField = ({
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
<p
ref={signatureRef}
className="w-full overflow-hidden break-all text-center font-signature leading-tight text-muted-foreground duration-200"
className="w-full overflow-hidden break-all text-center font-signature text-muted-foreground leading-tight duration-200"
style={{ fontSize: `${fontSize}rem` }}
>
{signature?.typedSignature}
@@ -274,8 +267,7 @@ export const DocumentSigningSignatureField = ({
<DialogContent>
<DialogTitle>
<Trans>
Sign as {recipient.name}{' '}
<div className="h-5 text-muted-foreground">({recipient.email})</div>
Sign as {recipient.name} <div className="h-5 text-muted-foreground">({recipient.email})</div>
</Trans>
</DialogTitle>
@@ -304,12 +296,7 @@ export const DocumentSigningSignatureField = ({
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
className="flex-1"
disabled={!localSignature}
onClick={() => onDialogSignClick()}
>
<Button type="button" className="flex-1" disabled={!localSignature} onClick={() => onDialogSignClick()}>
<Trans>Sign</Trans>
</Button>
</div>
@@ -1,10 +1,3 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { useRevalidator } from 'react-router';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@@ -21,6 +14,11 @@ import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useRevalidator } from 'react-router';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
@@ -48,11 +46,7 @@ export type TextFieldProps = {
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DocumentSigningTextField = ({
field,
onSignField,
onUnsignField,
}: DocumentSigningTextFieldProps) => {
export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }: DocumentSigningTextFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
@@ -71,10 +65,8 @@ export const DocumentSigningTextField = ({
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { mutateAsync: removeSignedFieldWithToken, isPending: isRemoveSignedFieldWithTokenLoading } =
trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const safeFieldMeta = ZTextFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
@@ -234,19 +226,11 @@ export const DocumentSigningTextField = ({
const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0);
return (
<DocumentSigningFieldContainer
field={field}
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Text"
>
<DocumentSigningFieldContainer field={field} onPreSign={onPreSign} onSign={onSign} onRemove={onRemove} type="Text">
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
<DocumentSigningFieldsUninserted>
{fieldDisplayName || <Trans>Text</Trans>}
</DocumentSigningFieldsUninserted>
<DocumentSigningFieldsUninserted>{fieldDisplayName || <Trans>Text</Trans>}</DocumentSigningFieldsUninserted>
)}
{field.inserted && (
@@ -257,9 +241,7 @@ export const DocumentSigningTextField = ({
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
<DialogContent>
<DialogTitle>
{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Text</Trans>}
</DialogTitle>
<DialogTitle>{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Text</Trans>}</DialogTitle>
<div>
<Textarea
@@ -1,26 +1,17 @@
import { createContext, useContext, useMemo, useState } from 'react';
import {
EnvelopeType,
type Field,
FieldType,
type Recipient,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { prop, sortBy } from 'remeda';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import {
isFieldUnsignedAndRequired,
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { trpc } from '@documenso/trpc/react';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { EnvelopeType, type Field, FieldType, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { createContext, useContext, useMemo, useState } from 'react';
import { prop, sortBy } from 'remeda';
export type EnvelopeSigningContextValue = {
isDirectTemplate: boolean;
@@ -83,6 +74,52 @@ export interface EnvelopeSigningProviderProps {
children: React.ReactNode;
}
/**
* Inject prefilled date fields for the current recipient.
*
* The dates are filled in correctly when the recipient "completes" the document.
*/
const prefillDateFields = (data: EnvelopeForSigningResponse): EnvelopeForSigningResponse => {
const { timezone, dateFormat } = data.envelope.documentMeta;
const formattedDate = DateTime.now()
.setZone(timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
.toFormat(dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
const prefillField = <T extends { type: FieldType; inserted: boolean; customText: string; fieldMeta: unknown }>(
field: T,
): T => {
if (field.type !== FieldType.DATE || field.inserted) {
return field;
}
return {
...field,
customText: formattedDate,
inserted: true,
fieldMeta: {
...(typeof field.fieldMeta === 'object' ? field.fieldMeta : {}),
readOnly: true,
},
};
};
return {
...data,
envelope: {
...data.envelope,
recipients: data.envelope.recipients.map((recipient) => ({
...recipient,
fields: recipient.fields.map(prefillField),
})),
},
recipient: {
...data.recipient,
fields: data.recipient.fields.map(prefillField),
},
};
};
export const EnvelopeSigningProvider = ({
fullName: initialFullName,
email: initialEmail,
@@ -90,7 +127,7 @@ export const EnvelopeSigningProvider = ({
envelopeData: initialEnvelopeData,
children,
}: EnvelopeSigningProviderProps) => {
const [envelopeData, setEnvelopeData] = useState(initialEnvelopeData);
const [envelopeData, setEnvelopeData] = useState(() => prefillDateFields(initialEnvelopeData));
const { envelope, recipient } = envelopeData;
@@ -121,9 +158,7 @@ export const EnvelopeSigningProvider = ({
},
recipient: {
...prev.recipient,
fields: prev.recipient.fields.map((field) =>
field.id === data.signedField.id ? data.signedField : field,
),
fields: prev.recipient.fields.map((field) => (field.id === data.signedField.id ? data.signedField : field)),
},
}));
},
@@ -137,25 +172,17 @@ export const EnvelopeSigningProvider = ({
if (
!sig &&
(envelope.documentMeta.uploadSignatureEnabled ||
envelope.documentMeta.drawSignatureEnabled) &&
(envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled) &&
envelopeData.recipientSignature?.signatureImageAsBase64
) {
return envelopeData.recipientSignature.signatureImageAsBase64;
}
if (
!sig &&
envelope.documentMeta.typedSignatureEnabled &&
envelopeData.recipientSignature?.typedSignature
) {
if (!sig && envelope.documentMeta.typedSignatureEnabled && envelopeData.recipientSignature?.typedSignature) {
return envelopeData.recipientSignature.typedSignature;
}
if (
isBase64 &&
(envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled)
) {
if (isBase64 && (envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled)) {
return sig;
}
@@ -174,9 +201,7 @@ export const EnvelopeSigningProvider = ({
const requiredFields = envelopeData.recipient.fields
.filter((field) => isFieldUnsignedAndRequired(field))
.map((field) => {
const envelopeItem = envelope.envelopeItems.find(
(item) => item.id === field.envelopeItemId,
);
const envelopeItem = envelope.envelopeItems.find((item) => item.id === field.envelopeItemId);
if (!envelopeItem) {
throw new Error('Missing envelope item');
@@ -228,8 +253,7 @@ export const EnvelopeSigningProvider = ({
recipient.role === RecipientRole.ASSISTANT
? assistantRecipients
.filter((r) => r.signingStatus !== SigningStatus.SIGNED)
.map((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE))
.flat()
.flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE))
: [];
/**
@@ -266,19 +290,24 @@ export const EnvelopeSigningProvider = ({
.filter((field) => field.inserted);
const nextRecipient = useMemo(() => {
if (
!envelope.documentMeta.signingOrder ||
envelope.documentMeta.signingOrder !== 'SEQUENTIAL'
) {
if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
return null;
}
const sortedRecipients = envelope.recipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
});
@@ -311,10 +340,7 @@ export const EnvelopeSigningProvider = ({
return signedField;
};
const handleDirectTemplateFieldInsertion = (
fieldId: number,
fieldValue: TSignEnvelopeFieldValue,
) => {
const handleDirectTemplateFieldInsertion = (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
const foundField = recipient.fields.find((field) => field.id === fieldId);
if (!foundField) {
@@ -1,28 +1,21 @@
import { useState } from 'react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Paperclip, Plus, X } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentAttachmentsPopoverProps = {
envelopeId: string;
buttonClassName?: string;
@@ -36,6 +29,7 @@ const ZAttachmentFormSchema = z.object({
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
// NOTE: REMEMBER TO UPDATE THE EMBEDDED VERSION OF THIS COMPONENT TOO.
export const DocumentAttachmentsPopover = ({
envelopeId,
buttonClassName,
@@ -49,16 +43,22 @@ export const DocumentAttachmentsPopover = ({
const utils = trpc.useUtils();
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId,
});
const { data: attachments } = trpc.envelope.attachment.find.useQuery(
{
envelopeId,
},
{
// Note: The invalidation of the query is manually handled by the onSuccess
// callbacks below for create and delete mutations.
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const { mutateAsync: createAttachment, isPending: isCreating } =
trpc.envelope.attachment.create.useMutation({
onSuccess: () => {
void utils.envelope.attachment.find.invalidate({ envelopeId });
},
});
const { mutateAsync: createAttachment, isPending: isCreating } = trpc.envelope.attachment.create.useMutation({
onSuccess: () => {
void utils.envelope.attachment.find.invalidate({ envelopeId });
},
});
const { mutateAsync: deleteAttachment } = trpc.envelope.attachment.delete.useMutation({
onSuccess: () => {
@@ -130,9 +130,7 @@ export const DocumentAttachmentsPopover = ({
<span>
<Trans>Attachments</Trans>
{attachments && attachments.data.length > 0 && (
<span className="ml-1">({attachments.data.length})</span>
)}
{attachments && attachments.data.length > 0 && <span className="ml-1">({attachments.data.length})</span>}
</span>
</Button>
</PopoverTrigger>
@@ -143,7 +141,7 @@ export const DocumentAttachmentsPopover = ({
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="text-muted-foreground mt-1 text-sm">
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Add links to relevant documents or resources.</Trans>
</p>
</div>
@@ -153,15 +151,15 @@ export const DocumentAttachmentsPopover = ({
{attachments?.data.map((attachment) => (
<div
key={attachment.id}
className="border-border flex items-center justify-between rounded-md border p-2"
className="flex items-center justify-between rounded-md border border-border p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.label}</p>
<p className="truncate font-medium text-sm">{attachment.label}</p>
<a
href={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
>
{attachment.data}
</a>
@@ -181,12 +179,7 @@ export const DocumentAttachmentsPopover = ({
)}
{!isAdding && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsAdding(true)}
>
<Button variant="outline" size="sm" className="w-full" onClick={() => setIsAdding(true)}>
<Plus className="mr-2 h-4 w-4" />
<Trans>Add Attachment</Trans>
</Button>
@@ -1,29 +1,24 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DownloadIcon } from 'lucide-react';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { base64 } from '@documenso/lib/universal/base64';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DownloadIcon } from 'lucide-react';
export type DocumentAuditLogDownloadButtonProps = {
className?: string;
documentId: number;
};
export const DocumentAuditLogDownloadButton = ({
className,
documentId,
}: DocumentAuditLogDownloadButtonProps) => {
export const DocumentAuditLogDownloadButton = ({ className, documentId }: DocumentAuditLogDownloadButtonProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const { mutateAsync: downloadAuditLogs, isPending } =
trpc.document.auditLog.download.useMutation();
const { mutateAsync: downloadAuditLogs, isPending } = trpc.document.auditLog.download.useMutation();
const onDownloadAuditLogsClick = async () => {
try {
@@ -41,9 +36,7 @@ export const DocumentAuditLogDownloadButton = ({
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Sorry, we were unable to download the audit logs. Please try again later.`,
),
description: _(msg`Sorry, we were unable to download the audit logs. Please try again later.`),
variant: 'destructive',
});
}
@@ -1,9 +1,3 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentStatus } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { base64 } from '@documenso/lib/universal/base64';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
@@ -11,6 +5,11 @@ import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentStatus } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
export type DocumentCertificateDownloadButtonProps = {
className?: string;
@@ -26,8 +25,7 @@ export const DocumentCertificateDownloadButton = ({
const { toast } = useToast();
const { _ } = useLingui();
const { mutateAsync: downloadCertificate, isPending } =
trpc.document.downloadCertificate.useMutation();
const { mutateAsync: downloadCertificate, isPending } = trpc.document.downloadCertificate.useMutation();
const onDownloadCertificatesClick = async () => {
try {
@@ -45,9 +43,7 @@ export const DocumentCertificateDownloadButton = ({
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Sorry, we were unable to download the certificate. Please try again later.`,
),
description: _(msg`Sorry, we were unable to download the certificate. Please try again later.`),
variant: 'destructive',
});
}
@@ -1,17 +1,11 @@
import { lazy, useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -21,15 +15,18 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Trans } from '@lingui/react/macro';
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useEffect, useState } from 'react';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
const EnvelopeGenericPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
);
import { EnvelopeGenericPageRenderer } from '../envelope-editor/envelope-generic-page-renderer';
export type DocumentCertificateQRViewProps = {
documentId: number;
@@ -58,9 +55,7 @@ export const DocumentCertificateQRView = ({
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentViaUser);
const formattedDate = completedDate
? DateTime.fromJSDate(completedDate).toLocaleString(DateTime.DATETIME_MED)
: '';
const formattedDate = completedDate ? DateTime.fromJSDate(completedDate).toLocaleString(DateTime.DATETIME_MED) : '';
useEffect(() => {
if (documentViaUser) {
@@ -81,8 +76,8 @@ export const DocumentCertificateQRView = ({
<DialogDescription>
<Trans>
This document is available in your Documenso account. You can view more details,
recipients, and audit logs there.
This document is available in your Documenso account. You can view more details, recipients, and audit
logs there.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -104,11 +99,13 @@ export const DocumentCertificateQRView = ({
{internalVersion === 2 ? (
<EnvelopeRenderProvider
version="current"
envelope={{
envelopeItems,
id: envelopeItems[0].envelopeId,
status: DocumentStatus.COMPLETED,
type: EnvelopeType.DOCUMENT,
}}
envelopeItems={envelopeItems}
token={token}
>
<DocumentCertificateQrV2
@@ -122,8 +119,8 @@ export const DocumentCertificateQRView = ({
<>
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
<div className="space-y-1">
<h1 className="text-xl font-medium">{title}</h1>
<div className="flex flex-col gap-0.5 text-sm text-muted-foreground">
<h1 className="font-medium text-xl">{title}</h1>
<div className="flex flex-col gap-0.5 text-muted-foreground text-sm">
<p>
<Trans>{recipientCount} recipients</Trans>
</p>
@@ -150,10 +147,16 @@ export const DocumentCertificateQRView = ({
<div className="mt-12 w-full">
<PDFViewerLazy
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={token}
version="signed"
key={envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItems[0]?.envelopeId,
envelopeItemId: envelopeItems[0]?.id,
documentDataId: envelopeItems[0]?.documentDataId,
version: 'current',
token,
presignToken: undefined,
})}
scrollParentRef="window"
/>
</div>
</>
@@ -169,20 +172,15 @@ type DocumentCertificateQrV2Props = {
token: string;
};
const DocumentCertificateQrV2 = ({
title,
recipientCount,
formattedDate,
token,
}: DocumentCertificateQrV2Props) => {
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
const DocumentCertificateQrV2 = ({ title, recipientCount, formattedDate, token }: DocumentCertificateQrV2Props) => {
const { envelopeItems } = useCurrentEnvelopeRender();
return (
<div className="flex min-h-screen flex-col items-start">
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
<div className="space-y-1">
<h1 className="text-xl font-medium">{title}</h1>
<div className="flex flex-col gap-0.5 text-sm text-muted-foreground">
<h1 className="font-medium text-xl">{title}</h1>
<div className="flex flex-col gap-0.5 text-muted-foreground text-sm">
<p>
<Trans>{recipientCount} recipients</Trans>
</p>
@@ -210,7 +208,11 @@ const DocumentCertificateQrV2 = ({
<div className="mt-12 w-full">
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
<EnvelopePdfViewer
scrollParentRef="window"
customPageRenderer={EnvelopeGenericPageRenderer}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
</div>
</div>
);
@@ -1,19 +1,9 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
import { useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -27,10 +17,16 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useCurrentTeam } from '~/providers/team';
export type DocumentEditFormProps = {
@@ -42,11 +38,7 @@ export type DocumentEditFormProps = {
type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject';
const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields', 'subject'];
export const DocumentEditForm = ({
className,
initialDocument,
documentRootPath,
}: DocumentEditFormProps) => {
export const DocumentEditForm = ({ className, initialDocument, documentRootPath }: DocumentEditFormProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@@ -162,9 +154,7 @@ export const DocumentEditForm = ({
const saveSettingsData = async (data: TAddSettingsFormSchema) => {
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema)
.safeParse(data.globalAccessAuth);
const parsedGlobalAccessAuth = z.array(ZDocumentAccessAuthTypesSchema).safeParse(data.globalAccessAuth);
return updateDocument({
documentId: document.id,
@@ -342,8 +332,7 @@ export const DocumentEditForm = ({
};
const saveSubjectData = async (data: TAddSubjectFormSchema) => {
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
data.meta;
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } = data.meta;
return updateDocument({
documentId: document.id,
@@ -359,8 +348,7 @@ export const DocumentEditForm = ({
};
const sendDocumentWithSubject = async (data: TAddSubjectFormSchema) => {
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
data.meta;
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } = data.meta;
return sendDocument({
documentId: document.id,
@@ -435,26 +423,26 @@ export const DocumentEditForm = ({
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<Card className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7" gradient>
<CardContent className="p-2">
<PDFViewerLazy
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={undefined}
version="signed"
key={document.envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: document.envelopeId,
envelopeItemId: document.envelopeItems[0]?.id,
documentDataId: initialDocument.documentDataId,
version: 'current',
token: undefined,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()}
>
<DocumentFlowFormContainer className="lg:h-[calc(100vh-6rem)]" onSubmit={(e) => e.preventDefault()}>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
@@ -1,14 +1,13 @@
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
@@ -43,19 +42,19 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
<Pencil className="mr-2 -ml-1 h-4 w-4" />
<Trans>Sign</Trans>
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
<CheckCircle className="mr-2 -ml-1 h-4 w-4" />
<Trans>Approve</Trans>
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
<EyeIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>View</Trans>
</>
))}
@@ -77,7 +76,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
token={recipient?.token}
trigger={
<Button className="w-full">
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Download className="mr-2 -ml-1 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
}
@@ -1,25 +1,9 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import {
Copy,
Download,
Edit,
Loader,
MoreHorizontal,
ScrollTextIcon,
Share,
Trash2,
} from 'lucide-react';
import { Link, useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { getEnvelopeItemPermissions, mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import {
DropdownMenu,
@@ -28,12 +12,29 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import {
Copy,
Download,
Edit,
FileOutputIcon,
Loader,
MoreHorizontal,
Pencil,
ScrollTextIcon,
Share,
Trash2,
} from 'lucide-react';
import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRenameDialog } from '~/components/dialogs/envelope-rename-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useCurrentTeam } from '~/providers/team';
@@ -43,14 +44,14 @@ export type DocumentPageViewDropdownProps = {
export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownProps) => {
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const navigate = useNavigate();
const team = useCurrentTeam();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const trpcUtils = trpcReact.useUtils();
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
@@ -62,14 +63,16 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
const isCurrentTeamDocument = team && envelope.teamId === team.id;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const { canTitleBeChanged } = getEnvelopeItemPermissions(envelope, []);
const documentsPath = formatDocumentsPath(team.url);
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
<DropdownMenuTrigger data-testid="document-page-view-action-btn">
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="end" forceMount>
@@ -86,10 +89,18 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
</DropdownMenuItem>
)}
{canManageDocument && canTitleBeChanged && (
<DropdownMenuItem onClick={() => setRenameDialogOpen(true)}>
<Pencil className="mr-2 h-4 w-4" />
<Trans>Rename</Trans>
</DropdownMenuItem>
)}
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
token={recipient?.token}
isLegacy={envelope.internalVersion === 1}
token={canManageDocument ? undefined : recipient?.token}
envelopeItems={envelope.envelopeItems}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
@@ -108,15 +119,42 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
<Trans>Duplicate</Trans>
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={EnvelopeType.DOCUMENT}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Copy className="mr-2 h-4 w-4" />
<Trans>Duplicate</Trans>
</div>
</DropdownMenuItem>
}
/>
<DropdownMenuItem onClick={() => setSaveAsTemplateDialogOpen(true)}>
<FileOutputIcon className="mr-2 h-4 w-4" />
<Trans>Save as Template</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={isDeleted}>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
<EnvelopeDeleteDialog
id={envelope.id}
type={EnvelopeType.DOCUMENT}
status={envelope.status}
title={envelope.title}
canManageDocument={canManageDocument}
onDelete={() => {
void navigate(documentsPath);
}}
trigger={
<DropdownMenuItem asChild disabled={isDeleted} onSelect={(e) => e.preventDefault()}>
<div>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</div>
</DropdownMenuItem>
}
/>
<DropdownMenuLabel>
<Trans>Share</Trans>
@@ -126,10 +164,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
<DocumentRecipientLinkCopyDialog
recipients={envelope.recipients}
trigger={
<DropdownMenuItem
disabled={!isPending || isDeleted}
onSelect={(e) => e.preventDefault()}
>
<DropdownMenuItem disabled={!isPending || isDeleted} onSelect={(e) => e.preventDefault()}>
<Copy className="mr-2 h-4 w-4" />
<Trans>Signing Links</Trans>
</DropdownMenuItem>
@@ -159,26 +194,21 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
/>
</DropdownMenuContent>
<DocumentDeleteDialog
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
status={envelope.status}
documentTitle={envelope.title}
open={isDeleteDialogOpen}
canManageDocument={canManageDocument}
onOpenChange={setDeleteDialogOpen}
onDelete={() => {
void navigate(documentsPath);
}}
<EnvelopeSaveAsTemplateDialog
envelopeId={envelope.id}
open={isSaveAsTemplateDialogOpen}
onOpenChange={setSaveAsTemplateDialogOpen}
/>
{isDuplicateDialogOpen && (
<DocumentDuplicateDialog
id={envelope.id}
token={recipient?.token}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>
)}
<EnvelopeRenameDialog
id={envelope.id}
initialTitle={envelope.title}
open={isRenameDialogOpen}
onOpenChange={setRenameDialogOpen}
onSuccess={async () => {
await trpcUtils.envelope.get.invalidate();
}}
/>
</DropdownMenu>
);
};
@@ -1,23 +1,18 @@
import { useMemo } from 'react';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { useMemo } from 'react';
export type DocumentPageViewInformationProps = {
userId: number;
envelope: TEnvelope;
};
export const DocumentPageViewInformation = ({
envelope,
userId,
}: DocumentPageViewInformationProps) => {
export const DocumentPageViewInformation = ({ envelope, userId }: DocumentPageViewInformationProps) => {
const isMounted = useIsMounted();
const { _, i18n } = useLingui();
@@ -26,8 +21,7 @@ export const DocumentPageViewInformation = ({
return [
{
description: msg`Uploaded by`,
value:
userId === envelope.userId ? _(msg`You`) : (envelope.user.name ?? envelope.user.email),
value: userId === envelope.userId ? _(msg`You`) : (envelope.user.name ?? envelope.user.email),
},
{
description: msg`Created`,
@@ -50,17 +44,14 @@ export const DocumentPageViewInformation = ({
}, [isMounted, envelope, userId]);
return (
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
<section className="flex flex-col rounded-xl border border-border bg-widget text-foreground dark:bg-background">
<h1 className="px-4 py-3 font-medium">
<Trans>Information</Trans>
</h1>
<ul className="divide-y border-t">
{documentInformation.map((item, i) => (
<li
key={i}
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
>
<li key={i} className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b">
<span className="text-muted-foreground">{_(item.description)}</span>
<span>{item.value}</span>
</li>
@@ -1,49 +1,37 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle, CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle, CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
import { DateTime } from 'luxon';
import { useMemo } from 'react';
import { match } from 'ts-pattern';
export type DocumentPageViewRecentActivityProps = {
documentId: number;
userId: number;
};
export const DocumentPageViewRecentActivity = ({
documentId,
userId,
}: DocumentPageViewRecentActivityProps) => {
export const DocumentPageViewRecentActivity = ({ documentId, userId }: DocumentPageViewRecentActivityProps) => {
const { _, i18n } = useLingui();
const {
data,
isLoading,
isLoadingError,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = trpc.document.auditLog.find.useInfiniteQuery(
{
documentId,
filterForRecentActivity: true,
orderByColumn: 'createdAt',
orderByDirection: 'asc',
perPage: 10,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
);
const { data, isLoading, isLoadingError, refetch, hasNextPage, fetchNextPage, isFetchingNextPage } =
trpc.document.auditLog.find.useInfiniteQuery(
{
documentId,
filterForRecentActivity: true,
orderByColumn: 'createdAt',
orderByDirection: 'asc',
perPage: 10,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
);
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
@@ -65,12 +53,12 @@ export const DocumentPageViewRecentActivity = ({
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center py-16">
<p className="text-sm text-foreground/80">
<p className="text-foreground/80 text-sm">
<Trans>Unable to load document history</Trans>
</p>
<button
onClick={async () => refetch()}
className="mt-2 text-sm text-foreground/70 hover:text-muted-foreground"
className="mt-2 text-foreground/70 text-sm hover:text-muted-foreground"
>
<Trans>Click here to retry</Trans>
</button>
@@ -82,7 +70,7 @@ export const DocumentPageViewRecentActivity = ({
<ul role="list" className="space-y-6 p-4">
{hasNextPage && (
<li className="relative flex gap-x-4">
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
<div className="absolute top-0 -bottom-6 left-0 flex w-6 justify-center">
<div className="w-px bg-border" />
</div>
@@ -92,7 +80,7 @@ export const DocumentPageViewRecentActivity = ({
<button
onClick={async () => fetchNextPage()}
className="text-xs text-foreground/70 hover:text-muted-foreground"
className="text-foreground/70 text-xs hover:text-muted-foreground"
>
{isFetchingNextPage ? _(msg`Loading...`) : _(msg`Load older activity`)}
</button>
@@ -101,7 +89,7 @@ export const DocumentPageViewRecentActivity = ({
{documentAuditLogs.length === 0 && (
<div className="flex items-center justify-center py-4">
<p className="text-sm text-muted-foreground/70">
<p className="text-muted-foreground/70 text-sm">
<Trans>No recent activity</Trans>
</p>
</div>
@@ -112,7 +100,7 @@ export const DocumentPageViewRecentActivity = ({
<div
className={cn(
auditLogIndex === documentAuditLogs.length - 1 ? 'h-6' : '-bottom-6',
'absolute left-0 top-0 flex w-6 justify-center',
'absolute top-0 left-0 flex w-6 justify-center',
)}
>
<div className="w-px bg-border" />
@@ -146,13 +134,13 @@ export const DocumentPageViewRecentActivity = ({
</div>
<p
className="flex-auto truncate py-0.5 text-xs leading-5 text-muted-foreground dark:text-muted-foreground/70"
className="flex-auto truncate py-0.5 text-muted-foreground text-xs leading-5 dark:text-muted-foreground/70"
title={formatDocumentAuditLogAction(i18n, auditLog, userId).description}
>
{formatDocumentAuditLogAction(i18n, auditLog, userId).description}
</p>
<time className="flex-none py-0.5 text-xs leading-5 text-muted-foreground dark:text-muted-foreground/70">
<time className="flex-none py-0.5 text-muted-foreground text-xs leading-5 dark:text-muted-foreground/70">
{DateTime.fromJSDate(auditLog.createdAt).toRelative({ style: 'short' })}
</time>
</li>
@@ -1,5 +1,14 @@
import { useEffect, useState } from 'react';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatSigningLink, isRecipientExpired } from '@documenso/lib/utils/recipients';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
@@ -9,42 +18,25 @@ import {
AlertTriangle,
CheckIcon,
Clock,
Clock8Icon,
MailIcon,
MailOpenIcon,
PenIcon,
PlusIcon,
UserIcon,
} from 'lucide-react';
import { DateTime } from 'luxon';
import { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewRecipientsProps = {
envelope: TEnvelope;
documentRootPath: string;
};
export const DocumentPageViewRecipients = ({
envelope,
documentRootPath,
}: DocumentPageViewRecipientsProps) => {
const { _ } = useLingui();
export const DocumentPageViewRecipients = ({ envelope, documentRootPath }: DocumentPageViewRecipientsProps) => {
const { _, i18n } = useLingui();
const { toast } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
@@ -66,9 +58,9 @@ export const DocumentPageViewRecipients = ({
}, [searchParams, setSearchParams]);
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<section className="flex flex-col rounded-xl border border-border bg-widget dark:bg-background">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">
<h1 className="font-medium text-foreground">
<Trans>Recipients</Trans>
</h1>
@@ -78,16 +70,12 @@ export const DocumentPageViewRecipients = ({
title={_(msg`Modify recipients`)}
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
<PlusIcon className="ml-2 h-4 w-4" />
) : (
<PenIcon className="ml-2 h-3 w-3" />
)}
{recipients.length === 0 ? <PlusIcon className="ml-2 h-4 w-4" /> : <PenIcon className="ml-2 h-3 w-3" />}
</Link>
)}
</div>
<ul className="text-muted-foreground divide-y border-t">
<ul className="divide-y border-t text-muted-foreground">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
@@ -107,83 +95,106 @@ export const DocumentPageViewRecipients = ({
/>
<div className="flex flex-row items-center">
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.SIGNED && (
<Badge variant="default">
{match(recipient.role)
.with(RecipientRole.APPROVER, () => (
{envelope.status !== DocumentStatus.DRAFT && recipient.signingStatus === SigningStatus.SIGNED && (
<Badge variant="default">
{match(recipient.role)
.with(RecipientRole.APPROVER, () => (
<>
<CheckIcon className="mr-1 h-3 w-3" />
<Trans>Approved</Trans>
</>
))
.with(RecipientRole.CC, () =>
envelope.status === DocumentStatus.COMPLETED ? (
<>
<MailIcon className="mr-1 h-3 w-3" />
<Trans>Sent</Trans>
</>
) : (
<>
<CheckIcon className="mr-1 h-3 w-3" />
<Trans>Approved</Trans>
<Trans>Ready</Trans>
</>
))
.with(RecipientRole.CC, () =>
envelope.status === DocumentStatus.COMPLETED ? (
<>
<MailIcon className="mr-1 h-3 w-3" />
<Trans>Sent</Trans>
</>
) : (
<>
<CheckIcon className="mr-1 h-3 w-3" />
<Trans>Ready</Trans>
</>
),
)
),
)
.with(RecipientRole.SIGNER, () => (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
))
.with(RecipientRole.VIEWER, () => (
<>
<MailOpenIcon className="mr-1 h-3 w-3" />
<Trans>Viewed</Trans>
</>
))
.with(RecipientRole.ASSISTANT, () => (
<>
<UserIcon className="mr-1 h-3 w-3" />
<Trans>Assisted</Trans>
</>
))
.exhaustive()}
.with(RecipientRole.SIGNER, () => (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
))
.with(RecipientRole.VIEWER, () => (
<>
<MailOpenIcon className="mr-1 h-3 w-3" />
<Trans>Viewed</Trans>
</>
))
.with(RecipientRole.ASSISTANT, () => (
<>
<UserIcon className="mr-1 h-3 w-3" />
<Trans>Assisted</Trans>
</>
))
.exhaustive()}
</Badge>
)}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
isRecipientExpired(recipient) && (
<Badge variant="destructive">
<Clock8Icon className="mr-1 h-3 w-3" />
<Trans>Expired</Trans>
</Badge>
)}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
!isRecipientExpired(recipient) &&
(recipient.expiresAt ? (
<PopoverHover
trigger={
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</Badge>
}
>
<p className="text-muted-foreground text-xs">
<Trans>
Expires {recipient.expiresAt ? i18n.date(recipient.expiresAt, DateTime.DATETIME_MED) : 'N/A'}
</Trans>
</p>
</PopoverHover>
) : (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</Badge>
)}
))}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.REJECTED && (
<PopoverHover
trigger={
<Badge variant="destructive">
<AlertTriangle className="mr-1 h-3 w-3" />
<Trans>Rejected</Trans>
</Badge>
}
>
<p className="text-sm">
<Trans>Reason for rejection: </Trans>
</p>
{envelope.status !== DocumentStatus.DRAFT && recipient.signingStatus === SigningStatus.REJECTED && (
<PopoverHover
trigger={
<Badge variant="destructive">
<AlertTriangle className="mr-1 h-3 w-3" />
<Trans>Rejected</Trans>
</Badge>
}
>
<p className="text-sm">
<Trans>Reason for rejection: </Trans>
</p>
<p className="text-muted-foreground mt-1 text-sm">
{recipient.rejectionReason}
</p>
</PopoverHover>
)}
<p className="mt-1 text-muted-foreground text-sm">{recipient.rejectionReason}</p>
</PopoverHover>
)}
{envelope.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && (
recipient.role !== RecipientRole.CC &&
!isRecipientExpired(recipient) && (
<TooltipProvider>
<Tooltip open={shouldHighlightCopyButtons && i === 0}>
<TooltipTrigger asChild>
@@ -196,9 +207,7 @@ export const DocumentPageViewRecipients = ({
onCopySuccess={() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(
msg`The signing link has been copied to your clipboard.`,
),
description: _(msg`The signing link has been copied to your clipboard.`),
});
setShouldHighlightCopyButtons(false);
}}
@@ -1,16 +1,8 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { useSearchParams } from 'react-router';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@@ -26,16 +18,19 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
export type DocumentRecipientLinkCopyDialogProps = {
trigger?: React.ReactNode;
recipients: Recipient[];
recipients: TRecipientLite[];
};
export const DocumentRecipientLinkCopyDialog = ({
trigger,
recipients,
}: DocumentRecipientLinkCopyDialogProps) => {
export const DocumentRecipientLinkCopyDialog = ({ trigger, recipients }: DocumentRecipientLinkCopyDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@@ -82,13 +77,11 @@ export const DocumentRecipientLinkCopyDialog = ({
</DialogTitle>
<DialogDescription>
<Trans>
You can copy and share these links to recipients so they can action the document.
</Trans>
<Trans>You can copy and share these links to recipients so they can action the document.</Trans>
</DialogDescription>
</DialogHeader>
<ul className="text-muted-foreground divide-y rounded-lg border">
<ul className="divide-y rounded-lg border text-muted-foreground">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
@@ -1,11 +1,9 @@
import { useCallback, useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useSearchParams } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
const { _ } = useLingui();
@@ -1,14 +1,12 @@
import type { HTMLAttributes } from 'react';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { CheckCircle2, Clock, File, XCircle } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils';
import type { HTMLAttributes } from 'react';
type FriendlyStatus = {
label: MessageDescriptor;
@@ -60,12 +58,7 @@ export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
inheritColor?: boolean;
};
export const DocumentStatus = ({
className,
status,
inheritColor,
...props
}: DocumentStatusProps) => {
export const DocumentStatus = ({ className, status, inheritColor, ...props }: DocumentStatusProps) => {
const { _ } = useLingui();
const { label, icon: Icon, color } = FRIENDLY_STATUS_MAP[status];
@@ -1,32 +1,26 @@
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useMemo, useState } from 'react';
import type { FileRejection } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentTeam } from '~/providers/team';
@@ -35,10 +29,7 @@ export type DocumentUploadButtonLegacyProps = {
type: EnvelopeType;
};
export const DocumentUploadButtonLegacy = ({
className,
type,
}: DocumentUploadButtonLegacyProps) => {
export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadButtonLegacyProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
@@ -162,10 +153,10 @@ export const DocumentUploadButtonLegacy = ({
}
};
const onFileDropRejected = () => {
const onFileDropRejected = (fileRejections: FileRejection[]) => {
toast({
title: _(msg`Your document failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
description: _(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
@@ -0,0 +1,202 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { nanoid } from '@documenso/lib/universal/id';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Paperclip, Plus, X } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
export type EmbeddedEditorAttachmentPopoverProps = {
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
};
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
// NOTE: REMEMBER TO UPDATE THE NON-EMBEDDED VERSION OF THIS COMPONENT TOO.
export const EmbeddedEditorAttachmentPopover = ({
buttonClassName,
buttonSize,
}: EmbeddedEditorAttachmentPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const { envelope, setLocalEnvelope } = useCurrentEnvelopeEditor();
const attachments = envelope.attachments ?? [];
const form = useForm<TAttachmentFormSchema>({
resolver: zodResolver(ZAttachmentFormSchema),
defaultValues: {
label: '',
url: '',
},
});
const onSubmit = (data: TAttachmentFormSchema) => {
setLocalEnvelope({
attachments: [
...attachments,
{
id: nanoid(),
type: 'link',
label: data.label,
data: data.url,
},
],
});
form.reset();
setIsAdding(false);
toast({
title: _(msg`Success`),
description: _(msg`Attachment added successfully.`),
});
};
const onDeleteAttachment = (id: string) => {
setLocalEnvelope({
attachments: attachments.filter((a) => a.id !== id),
});
toast({
title: _(msg`Success`),
description: _(msg`Attachment removed successfully.`),
});
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>
{attachments.length > 0 && <span className="ml-1">({attachments.length})</span>}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="end">
<div className="space-y-4">
<div>
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Add links to relevant documents or resources.</Trans>
</p>
</div>
{attachments.length > 0 && (
<div className="space-y-2">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="flex items-center justify-between rounded-md border border-border p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{attachment.label}</p>
<a
href={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
>
{attachment.data}
</a>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onDeleteAttachment(attachment.id)}
className="ml-2 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{!isAdding && (
<Button variant="outline" size="sm" className="w-full" onClick={() => setIsAdding(true)}>
<Plus className="mr-2 h-4 w-4" />
<Trans>Add Attachment</Trans>
</Button>
)}
{isAdding && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<FormField
control={form.control}
name="label"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder={_(msg`Label`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="url" placeholder={_(msg`URL`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="flex-1"
onClick={() => {
setIsAdding(false);
form.reset();
}}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" size="sm" className="flex-1">
<Trans>Add</Trans>
</Button>
</div>
</form>
</Form>
)}
</div>
</PopoverContent>
</Popover>
);
};
@@ -1,5 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
@@ -14,18 +23,7 @@ import {
TextIcon,
UserIcon,
} from 'lucide-react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36;
@@ -105,9 +103,7 @@ export const EnvelopeEditorFieldDragDrop = ({
const { isWithinPageBounds, getPage } = useDocumentElement();
const isFieldsDisabled = useMemo(() => {
const selectedSigner = envelope.recipients.find(
(recipient) => recipient.id === selectedRecipientId,
);
const selectedSigner = envelope.recipients.find((recipient) => recipient.id === selectedRecipientId);
const fields = envelope.fields;
if (!selectedSigner) {
@@ -136,12 +132,7 @@ export const EnvelopeEditorFieldDragDrop = ({
const onMouseMove = useCallback(
(event: MouseEvent) => {
setIsFieldWithinBounds(
isWithinPageBounds(
event,
PDF_VIEWER_PAGE_SELECTOR,
fieldBounds.current.width,
fieldBounds.current.height,
),
isWithinPageBounds(event, PDF_VIEWER_PAGE_SELECTOR, fieldBounds.current.width, fieldBounds.current.height),
);
setCoords({
@@ -162,12 +153,7 @@ export const EnvelopeEditorFieldDragDrop = ({
if (
!$page ||
!isWithinPageBounds(
event,
PDF_VIEWER_PAGE_SELECTOR,
fieldBounds.current.width,
fieldBounds.current.height,
)
!isWithinPageBounds(event, PDF_VIEWER_PAGE_SELECTOR, fieldBounds.current.width, fieldBounds.current.height)
) {
setSelectedField(null);
return;
@@ -175,15 +161,6 @@ export const EnvelopeEditorFieldDragDrop = ({
const { top, left, height, width } = getBoundingClientRect($page);
console.log({
top,
left,
height,
width,
rawPageX: event.pageX,
rawPageY: event.pageY,
});
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height
@@ -216,14 +193,7 @@ export const EnvelopeEditorFieldDragDrop = ({
setIsFieldWithinBounds(false);
setSelectedField(null);
},
[
isWithinPageBounds,
selectedField,
selectedRecipientId,
selectedEnvelopeItemId,
getPage,
editorFields,
],
[isWithinPageBounds, selectedField, selectedRecipientId, selectedEnvelopeItemId, getPage, editorFields],
);
useEffect(() => {
@@ -262,9 +232,10 @@ export const EnvelopeEditorFieldDragDrop = ({
};
}, [onMouseClick, onMouseMove, selectedField]);
const selectedRecipientColor = useMemo(() => {
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
}, [selectedRecipientId, getRecipientColorKey]);
const selectedRecipientStyles = useMemo(
() => getRecipientColorStyles(getRecipientColorKey(selectedRecipientId ?? -1)),
[selectedRecipientId, getRecipientColorKey],
);
return (
<>
@@ -278,22 +249,15 @@ export const EnvelopeEditorFieldDragDrop = ({
onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined}
className={cn(
'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors',
selectedRecipientStyles.fieldButton,
)}
>
<p
className={cn(
'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 font-normal font-noto text-muted-foreground text-sm group-data-[selected]:text-foreground',
field.className,
{
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
'group-hover:text-recipient-blue': selectedRecipientColor === 'blue',
'group-hover:text-recipient-purple': selectedRecipientColor === 'purple',
'group-hover:text-recipient-orange': selectedRecipientColor === 'orange',
'group-hover:text-recipient-yellow': selectedRecipientColor === 'yellow',
'group-hover:text-recipient-pink': selectedRecipientColor === 'pink',
},
selectedRecipientStyles.fieldButtonText,
)}
>
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
@@ -306,8 +270,8 @@ export const EnvelopeEditorFieldDragDrop = ({
{selectedField && (
<div
className={cn(
'text-muted-foreground dark:text-muted-background font-noto pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted-background',
selectedRecipientStyles.base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
@@ -321,9 +285,7 @@ export const EnvelopeEditorFieldDragDrop = ({
width: fieldBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
{t(FRIENDLY_FIELD_TYPE[selectedField])}
</span>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(FRIENDLY_FIELD_TYPE[selectedField])}</span>
</div>
)}
</>
@@ -1,31 +1,32 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
type PageRenderData,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import {
convertPixelToPercentage,
MIN_FIELD_HEIGHT_PX,
MIN_FIELD_WIDTH_PX,
} from '@documenso/lib/universal/field-renderer/field-renderer';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { CommandDialog } from '@documenso/ui/primitives/command';
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Transformer } from 'konva/lib/shapes/Transformer';
import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import {
MIN_FIELD_HEIGHT_PX,
MIN_FIELD_WIDTH_PX,
convertPixelToPercentage,
} from '@documenso/lib/universal/field-renderer/field-renderer';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { CommandDialog } from '@documenso/ui/primitives/command';
import { useEffect, useMemo, useRef, useState } from 'react';
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
export default function EnvelopeEditorFieldsPageRenderer() {
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { t, i18n } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
@@ -37,34 +38,22 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const [isFieldChanging, setIsFieldChanging] = useState(false);
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer(
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
pageData,
);
const { _className, scale } = pageContext;
const { scale, pageNumber } = pageData;
const localPageFields = useMemo(
() =>
editorFields.localFields.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[editorFields.localFields, pageContext.pageNumber],
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const { current: container } = canvasElement;
if (!container) {
return;
}
const isDragEvent = event.type === 'dragend';
const fieldGroup = event.target as Konva.Group;
@@ -121,8 +110,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}
const recipient = envelope.recipients.find((r) => r.id === field.recipientId);
const isFieldEditable =
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
const isFieldEditable = recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
const { fieldGroup } = renderField({
scale,
@@ -205,9 +193,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
setIsFieldChanging(e.type === 'dragstart');
const itemAlreadySelected = (interactiveTransformer.current?.nodes() || []).includes(
e.target,
);
const itemAlreadySelected = (interactiveTransformer.current?.nodes() || []).includes(e.target);
// Do nothing and allow the transformer to handle it.
// Required so when multiple items are selected, this won't deselect them.
@@ -235,10 +221,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
* - Selecting multiple fields
* - Selecting empty area to create fields
*/
const createInteractiveTransformer = (
currentStage: Konva.Stage,
currentPageLayer: Konva.Layer,
) => {
const createInteractiveTransformer = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
const transformer = new Konva.Transformer({
rotateEnabled: false,
keepRatio: false,
@@ -344,7 +327,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
// Create a field if no items are selected or the size is too small.
if (
selectedFieldGroups.length === 0 &&
canvasElement.current &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient &&
@@ -365,13 +347,9 @@ export default function EnvelopeEditorFieldsPageRenderer() {
});
// Clicks should select/deselect shapes
currentStage.on('click tap', function (e) {
currentStage.on('click tap', (e) => {
// if we are selecting with rect, do nothing
if (
selectionRectangle.visible() &&
selectionRectangle.width() > 0 &&
selectionRectangle.height() > 0
) {
if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) {
return;
}
@@ -421,10 +399,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
// If doesn't exist in localFields, destroy it since it's been deleted.
pageLayer.current.find('Group').forEach((group) => {
if (
group.name() === 'field-group' &&
!localPageFields.some((field) => field.formId === group.id())
) {
if (group.name() === 'field-group' && !localPageFields.some((field) => field.formId === group.id())) {
group.destroy();
}
});
@@ -434,15 +409,30 @@ export default function EnvelopeEditorFieldsPageRenderer() {
renderFieldOnLayer(field);
});
// Reconcile selection state with live field nodes after flush/sync updates.
const liveSelectedFieldGroups = selectedKonvaFieldGroups.filter((fieldGroup) => {
if (!fieldGroup.getStage() || !fieldGroup.getParent()) {
return false;
}
return localPageFields.some((field) => field.formId === fieldGroup.id());
});
if (liveSelectedFieldGroups.length !== selectedKonvaFieldGroups.length) {
setSelectedFields(liveSelectedFieldGroups);
}
// Rerender the transformer
interactiveTransformer.current?.forceUpdate();
pageLayer.current.batchDraw();
}, [localPageFields]);
}, [localPageFields, selectedKonvaFieldGroups]);
const setSelectedFields = (nodes: Konva.Node[]) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const fieldGroups = nodes.filter((node) => node.hasName('field-group')) as Konva.Group[];
const fieldGroups = nodes.filter(
(node) => node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()),
) as Konva.Group[];
interactiveTransformer.current?.nodes(fieldGroups);
setSelectedKonvaFieldGroups(fieldGroups);
@@ -461,9 +451,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
};
const deletedSelectedFields = () => {
const fieldFormids = selectedKonvaFieldGroups
.map((field) => field.id())
.filter((field) => field !== undefined);
const fieldFormids = selectedKonvaFieldGroups.map((field) => field.id()).filter((field) => field !== undefined);
editorFields.removeFieldsByFormId(fieldFormids);
@@ -515,7 +503,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
removePendingField();
if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) {
if (!currentEnvelopeItem || !editorFields.selectedRecipient) {
return;
}
@@ -530,7 +518,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
page: pageContext.pageNumber,
page: pageNumber,
type,
positionX: fieldX,
positionY: fieldY,
@@ -559,51 +547,32 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}
return (
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current &&
!isFieldChanging && (
<FieldActionButtons
handleDuplicateSelectedFields={duplicatedSelectedFields}
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
style={{
position: 'absolute',
top:
interactiveTransformer.current.y() +
interactiveTransformer.current.getClientRect().height +
5 +
'px',
left:
interactiveTransformer.current.x() +
interactiveTransformer.current.getClientRect().width / 2 +
'px',
transform: 'translateX(-50%)',
gap: '8px',
pointerEvents: 'auto',
zIndex: 50,
}}
/>
)}
<>
{selectedKonvaFieldGroups.length > 0 && interactiveTransformer.current && !isFieldChanging && (
<FieldActionButtons
handleDuplicateSelectedFields={duplicatedSelectedFields}
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
style={{
position: 'absolute',
top: interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px',
left: interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px',
transform: 'translateX(-50%)',
gap: '8px',
pointerEvents: 'auto',
zIndex: 50,
}}
/>
)}
{pendingFieldCreation && (
<div
style={{
position: 'absolute',
top:
pendingFieldCreation.y() * scale +
pendingFieldCreation.getClientRect().height +
5 +
'px',
left:
pendingFieldCreation.x() * scale +
pendingFieldCreation.getClientRect().width / 2 +
'px',
top: pendingFieldCreation.y() * scale + pendingFieldCreation.getClientRect().height + 5 + 'px',
left: pendingFieldCreation.x() * scale + pendingFieldCreation.getClientRect().width / 2 + 'px',
transform: 'translateX(-50%)',
zIndex: 50,
}}
@@ -624,17 +593,9 @@ export default function EnvelopeEditorFieldsPageRenderer() {
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
</>
);
}
};
type FieldActionButtonsProps = React.HTMLAttributes<HTMLDivElement> & {
handleDuplicateSelectedFields: () => void;
@@ -670,13 +631,13 @@ const FieldActionButtons = ({
return null;
}
const fields = editorFields.localFields.filter((field) =>
selectedFieldFormId.includes(field.formId),
);
const fields = editorFields.localFields.filter((field) => selectedFieldFormId.includes(field.formId));
const recipient = envelope.recipients.find(
(recipient) => recipient.id === fields[0].recipientId,
);
if (fields.length === 0) {
return null;
}
const recipient = envelope.recipients.find((recipient) => recipient.id === fields[0].recipientId);
if (!recipient) {
return null;
@@ -689,7 +650,7 @@ const FieldActionButtons = ({
}
return null;
}, [editorFields.localFields]);
}, [editorFields.localFields, envelope.recipients, selectedFieldFormId]);
return (
<div className="flex flex-col items-center" {...props}>
@@ -731,11 +692,7 @@ const FieldActionButtons = ({
</button>
</div>
<CommandDialog
position="start"
open={showRecipientSelector}
onOpenChange={setShowRecipientSelector}
>
<CommandDialog position="start" open={showRecipientSelector} onOpenChange={setShowRecipientSelector}>
<EnvelopeRecipientSelectorCommand
placeholder={t`Select a recipient`}
selectedRecipient={preselectedRecipient}
@@ -1,17 +1,6 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, SparklesIcon } from 'lucide-react';
import { Link, useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import {
FIELD_META_DEFAULT_VALUES,
@@ -27,15 +16,27 @@ import {
type TSignatureFieldMeta,
type TTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
import { EnvelopeItemEditDialog } from '~/components/dialogs/envelope-item-edit-dialog';
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
@@ -46,16 +47,14 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
const EnvelopeEditorFieldsPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
);
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.SIGNATURE]: msg`Signature Settings`,
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
@@ -75,7 +74,9 @@ export const EnvelopeEditorFieldsPage = () => {
const team = useCurrentTeam();
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -85,11 +86,13 @@ export const EnvelopeEditorFieldsPage = () => {
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
const selectedField = useMemo(
() => structuredClone(editorFields.selectedField),
[editorFields.selectedField],
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
[envelope, envelope.recipients],
);
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
const updateSelectedFieldMeta = (fieldMeta: TFieldMetaSchema) => {
if (!selectedField) {
return;
@@ -97,14 +100,10 @@ export const EnvelopeEditorFieldsPage = () => {
const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta);
// Todo: Envelopes - Clean up console logs.
if (!isMetaSame) {
console.log('TRIGGER UPDATE');
editorFields.updateFieldByFormId(selectedField.formId, {
fieldMeta,
});
} else {
console.log('DATA IS SAME, NO UPDATE');
}
};
@@ -131,8 +130,7 @@ export const EnvelopeEditorFieldsPage = () => {
*/
useEffect(() => {
const firstSelectableRecipient = envelope.recipients.find(
(recipient) =>
recipient.role === RecipientRole.SIGNER || recipient.role === RecipientRole.APPROVER,
(recipient) => recipient.role === RecipientRole.SIGNER || recipient.role === RecipientRole.APPROVER,
);
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
@@ -156,12 +154,43 @@ export const EnvelopeEditorFieldsPage = () => {
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
<EnvelopeRendererFileSelector
className="px-0"
fields={editorFields.localFields}
renderItemAction={
editorConfig.envelopeItems !== null &&
editorConfig.envelopeItems.allowReplace &&
envelopeItemPermissions.canFileBeChanged
? (item) => (
<div className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
<div
className={cn('h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0', {
'bg-green-500': currentEnvelopeItem?.id === item.id,
})}
/>
<EnvelopeItemEditDialog
envelopeItem={item}
allowConfigureTitle={editorConfig.envelopeItems?.allowConfigureTitle ?? false}
trigger={
<span
className="absolute inset-0 flex cursor-pointer items-center justify-center opacity-0 transition-opacity duration-150 group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
data-testid={`envelope-item-edit-button-${item.id}`}
>
<PencilIcon className="h-3.5 w-3.5" />
</span>
}
/>
</div>
)
: undefined
}
/>
{/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center">
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
@@ -176,26 +205,25 @@ export const EnvelopeEditorFieldsPage = () => {
</AlertDescription>
</div>
<Button asChild variant="outline">
<Link to={`${relativePath.editorPath}`}>
<Trans>Add Recipients</Trans>
</Link>
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
<Trans>Add Recipients</Trans>
</Button>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy
renderer="editor"
<EnvelopePdfViewer
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
<p className="mt-1 text-sm text-foreground">
<p className="mt-1 text-foreground text-sm">
<Trans>No documents found</Trans>
</p>
<p className="mt-1 text-sm text-muted-foreground">
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Please upload a document to continue</Trans>
</p>
</div>
@@ -205,18 +233,16 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l border-border bg-background py-4">
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-4">
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 text-sm font-semibold text-foreground">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) =>
editorFields.setSelectedRecipient(recipient.id)
}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
@@ -228,8 +254,7 @@ export const EnvelopeEditorFieldsPage = () => {
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or
completed the document.
This recipient can no longer be modified as they have signed a field, or completed the document.
</Trans>
</AlertDescription>
</Alert>
@@ -240,7 +265,7 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 text-sm font-semibold text-foreground">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
@@ -249,36 +274,40 @@ export const EnvelopeEditorFieldsPage = () => {
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
@@ -290,25 +319,47 @@ export const EnvelopeEditorFieldsPage = () => {
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 text-sm font-semibold text-foreground">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-sm text-foreground">
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
</span>{' '}
{selectedField.id}
</p>
)}
<p>
<span className="min-w-12 text-muted-foreground">Pos X:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.recipientId}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Pos Y:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Width:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Height:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
</div>
@@ -318,10 +369,8 @@ export const EnvelopeEditorFieldsPage = () => {
</>
)}
<div className="px-4 [&_label]:text-xs [&_label]:text-foreground/70">
<h3 className="text-sm font-semibold">
{_(FieldSettingsTypeTranslations[selectedField.type])}
</h3>
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
@@ -1,27 +1,29 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { getEnvelopeItemPermissions, mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { DocumentStatus, EnvelopeType, TemplateType } from '@prisma/client';
import {
AlertTriangleIcon,
Building2Icon,
Globe2Icon,
LockIcon,
RefreshCwIcon,
SendIcon,
SettingsIcon,
} from 'lucide-react';
import { useMemo } from 'react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { BrandingLogo } from '~/components/general/branding-logo';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { EmbeddedEditorAttachmentPopover } from '~/components/general/document/embedded-editor-attachment-popover';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { TemplateDirectLinkBadge } from '../template/template-direct-link-badge';
@@ -30,21 +32,58 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
export default function EnvelopeEditorHeader() {
const { t } = useLingui();
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError, relativePath } =
useCurrentEnvelopeEditor();
const {
envelope,
isDocument,
isTemplate,
isEmbedded,
updateEnvelope,
autosaveError,
relativePath,
editorConfig,
flushAutosave,
} = useCurrentEnvelopeEditor();
const {
embedded,
general: { allowConfigureEnvelopeTitle },
actions: { allowAttachments, allowDistributing },
} = editorConfig;
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
[envelope, envelope.recipients],
);
const handleCreateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embedded?.onCreate?.(latestEnvelope);
};
const handleUpdateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embedded?.onUpdate?.(latestEnvelope);
};
return (
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
<Separator orientation="vertical" className="h-6" />
<nav className="w-full border-border border-b bg-background px-4 py-3 md:px-6">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 flex-1 items-center space-x-4">
{editorConfig.embedded?.customBrandingLogo ? (
<img src={`/api/branding/logo/team/${envelope.teamId}`} alt="Logo" className="h-6 w-auto" />
) : (
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
)}
<Separator orientation="vertical" className="h-6 shrink-0" />
<div className="flex items-center space-x-2">
<div className="flex min-w-0 items-center space-x-2">
<EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT}
dataTestId="envelope-title-input"
disabled={!envelopeItemPermissions.canTitleBeChanged || !allowConfigureEnvelopeTitle}
value={envelope.title}
onChange={(title) => {
updateEnvelope({
@@ -58,13 +97,20 @@ export default function EnvelopeEditorHeader() {
{envelope.type === EnvelopeType.TEMPLATE && (
<>
{envelope.templateType === 'PRIVATE' ? (
<Badge variant="secondary">
{envelope.templateType === TemplateType.PRIVATE && (
<Badge variant="secondary" className="shrink-0">
<LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" />
<Trans>Private Template</Trans>
</Badge>
) : (
<Badge variant="default">
)}
{envelope.templateType === TemplateType.ORGANISATION && (
<Badge variant="orange" className="shrink-0">
<Building2Icon className="mr-2 size-4" />
<Trans>Organisation Template</Trans>
</Badge>
)}
{envelope.templateType === TemplateType.PUBLIC && (
<Badge variant="default" className="shrink-0">
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
<Trans>Public Template</Trans>
</Badge>
@@ -72,7 +118,7 @@ export default function EnvelopeEditorHeader() {
{envelope.directLink?.token && (
<TemplateDirectLinkBadge
className="py-1"
className="shrink-0 py-1"
token={envelope.directLink.token}
enabled={envelope.directLink.enabled}
/>
@@ -83,22 +129,22 @@ export default function EnvelopeEditorHeader() {
{envelope.type === EnvelopeType.DOCUMENT &&
match(envelope.status)
.with(DocumentStatus.DRAFT, () => (
<Badge variant="warning">
<Badge variant="warning" className="shrink-0">
<Trans>Draft</Trans>
</Badge>
))
.with(DocumentStatus.PENDING, () => (
<Badge variant="secondary">
<Badge variant="secondary" className="shrink-0">
<Trans>Pending</Trans>
</Badge>
))
.with(DocumentStatus.COMPLETED, () => (
<Badge variant="default">
<Badge variant="default" className="shrink-0">
<Trans>Completed</Trans>
</Badge>
))
.with(DocumentStatus.REJECTED, () => (
<Badge variant="destructive">
<Badge variant="destructive" className="shrink-0">
<Trans>Rejected</Trans>
</Badge>
))
@@ -106,7 +152,7 @@ export default function EnvelopeEditorHeader() {
{autosaveError && (
<>
<Badge variant="destructive">
<Badge variant="destructive" className="shrink-0">
<AlertTriangleIcon className="mr-2 h-4 w-4" />
<Trans>Sync failed, changes not saved</Trans>
</Badge>
@@ -116,7 +162,7 @@ export default function EnvelopeEditorHeader() {
window.location.reload();
}}
>
<Badge variant="destructive">
<Badge variant="destructive" className="shrink-0">
<RefreshCwIcon className="mr-2 h-4 w-4" />
<Trans>Reload</Trans>
</Badge>
@@ -126,55 +172,76 @@ export default function EnvelopeEditorHeader() {
</div>
</div>
<div className="flex items-center space-x-2">
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
<div className="flex shrink-0 items-center space-x-2">
{allowAttachments &&
(isEmbedded ? (
<EmbeddedEditorAttachmentPopover buttonSize="sm" />
) : (
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
))}
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="outline" size="sm">
<SettingsIcon className="h-4 w-4" />
</Button>
}
/>
{isDocument && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
</>
)}
{isTemplate && (
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath}
{editorConfig.settings && (
<EnvelopeEditorSettingsDialog
trigger={
<Button size="sm">
<Trans>Use Template</Trans>
<Button variant="outline" size="sm">
<SettingsIcon className="h-4 w-4" />
</Button>
}
/>
)}
{match({ isEmbedded, isDocument, isTemplate, allowDistributing })
.with({ isEmbedded: false, isDocument: true, allowDistributing: true }, () => (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
</>
))
.with({ isEmbedded: false, isTemplate: true, allowDistributing: true }, () => (
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<Trans>Use Template</Trans>
</Button>
}
/>
))
.otherwise(() => null)}
{embedded?.mode === 'create' && (
<Button size="sm" onClick={handleCreateEmbeddedEnvelope}>
{isDocument ? <Trans>Create Document</Trans> : <Trans>Create Template</Trans>}
</Button>
)}
{embedded?.mode === 'edit' && (
<Button size="sm" onClick={handleUpdateEmbeddedEnvelope}>
{isDocument ? <Trans>Update Document</Trans> : <Trans>Update Template</Trans>}
</Button>
)}
</div>
</div>
</nav>
@@ -1,43 +1,52 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { faker } from '@faker-js/faker/locale/en';
import { Trans } from '@lingui/react/macro';
import { FieldType, SigningStatus } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator';
import type { Faker } from '@faker-js/faker';
import { Trans } from '@lingui/react/macro';
import { FieldType, SigningStatus } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { match } from 'ts-pattern';
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
const EnvelopeGenericPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
);
// Todo: Envelopes - Dynamically import faker
export const EnvelopeEditorPreviewPage = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { envelope, editorFields, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
'recipient',
);
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>('recipient');
const [fakerInstance, setFakerInstance] = useState<Faker | null>(null);
useEffect(() => {
void import('@faker-js/faker/locale/en').then((mod) => {
setFakerInstance(mod.faker);
});
}, []);
const fieldsWithPlaceholders = useMemo(() => {
if (!fakerInstance) {
return [];
}
const faker = fakerInstance;
return fields.map((field) => {
const fieldMeta = ZFieldAndMetaSchema.parse(field);
@@ -188,7 +197,7 @@ export const EnvelopeEditorPreviewPage = () => {
.exhaustive(),
};
});
}, [fields, envelope, envelope.recipients, envelope.documentMeta]);
}, [fields, envelope, envelope.recipients, envelope.documentMeta, fakerInstance]);
/**
* Set the selected recipient to the first recipient in the envelope.
@@ -200,45 +209,49 @@ export const EnvelopeEditorPreviewPage = () => {
// Override the parent renderer provider so we can inject custom fields.
return (
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={fieldsWithPlaceholders}
recipients={envelope.recipients.map((recipient) => ({
...recipient,
signingStatus: SigningStatus.SIGNED,
}))}
presignToken={editorConfig?.embedded?.presignToken}
overrideSettings={{
mode: 'export',
}}
>
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
<Alert variant="warning" className="mx-auto max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
{/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center">
<Alert variant="warning" className="mb-4 max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
<div className="mt-4 flex h-full flex-col items-center justify-center">
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy
renderer="editor"
<EnvelopePdfViewer
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
<p className="mt-1 text-sm text-foreground">
<p className="mt-1 text-foreground text-sm">
<Trans>No documents found</Trans>
</p>
<p className="mt-1 text-sm text-muted-foreground">
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Please upload a document to continue</Trans>
</p>
</div>
@@ -248,7 +261,7 @@ export const EnvelopeEditorPreviewPage = () => {
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && false && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-gray-200 border-l bg-white py-4">
{/* Add fields section. */}
<section className="px-4">
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
@@ -260,9 +273,7 @@ export const EnvelopeEditorPreviewPage = () => {
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Preview what the signed document will look like with placeholder data
</Trans>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
@@ -315,15 +326,13 @@ export const EnvelopeEditorPreviewPage = () => {
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 text-sm font-semibold text-gray-900">
<h3 className="mb-2 font-semibold text-gray-900 text-sm">
<Trans>Selected Recipient</Trans>
</h3>
<RecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) =>
editorFields.setSelectedRecipient(recipient.id)
}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
className="w-full"
align="end"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
export const EnvelopeEditorRenderProviderWrapper = ({
children,
token,
presignedToken,
}: {
children: React.ReactNode;
token?: string;
presignedToken?: string;
}) => {
const { envelope } = useCurrentEnvelopeEditor();
return (
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
fields={envelope.fields}
recipients={envelope.recipients}
token={token}
presignToken={presignedToken}
>
{children}
</EnvelopeRenderProvider>
);
};
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useEffect, useRef, useState } from 'react';
const MIN_INPUT_WIDTH = 100;
const INPUT_WIDTH_PADDING = 16;
export type EnvelopeItemTitleInputProps = {
value: string;
@@ -9,6 +11,7 @@ export type EnvelopeItemTitleInputProps = {
className?: string;
placeholder?: string;
disabled?: boolean;
dataTestId?: string;
};
export const EnvelopeItemTitleInput = ({
@@ -17,6 +20,7 @@ export const EnvelopeItemTitleInput = ({
className,
placeholder,
disabled,
dataTestId,
}: EnvelopeItemTitleInputProps) => {
const [envelopeItemTitle, setEnvelopeItemTitle] = useState(value);
const [isError, setIsError] = useState(false);
@@ -25,11 +29,12 @@ export const EnvelopeItemTitleInput = ({
const inputRef = useRef<HTMLInputElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
// Update input width based on content
useEffect(() => {
if (measureRef.current) {
const width = measureRef.current.offsetWidth;
setInputWidth(Math.max(width + 16, 100)); // Add padding and minimum width
const nextInputWidth = Math.max(width + INPUT_WIDTH_PADDING, MIN_INPUT_WIDTH);
setInputWidth(nextInputWidth);
}
}, [envelopeItemTitle]);
@@ -53,16 +58,17 @@ export const EnvelopeItemTitleInput = ({
};
return (
<div className="relative">
<div className="relative min-w-0 max-w-full shrink">
{/* Hidden span to measure text width */}
<span
ref={measureRef}
className="pointer-events-none absolute left-0 top-0 whitespace-nowrap text-sm font-medium text-gray-600 opacity-0"
className="pointer-events-none absolute top-0 left-0 whitespace-nowrap font-medium text-gray-600 text-sm opacity-0"
style={{ font: 'inherit' }}
>
{envelopeItemTitle || placeholder}
</span>
<input
data-testid={dataTestId}
data-1p-ignore
autoComplete="off"
ref={inputRef}
@@ -70,9 +76,9 @@ export const EnvelopeItemTitleInput = ({
value={envelopeItemTitle}
onChange={(e) => handleTitleChange(e.target.value)}
disabled={disabled}
style={{ width: `${inputWidth}px` }}
style={{ width: `${inputWidth}px`, maxWidth: '100%' }}
className={cn(
'text-foreground hover:outline-muted-foreground focus:outline-muted-foreground rounded-sm border-0 bg-transparent p-1 text-sm font-medium outline-none hover:outline hover:outline-1 focus:outline focus:outline-1',
'max-w-full rounded-sm border-0 bg-transparent p-1 font-medium text-foreground text-sm outline-none hover:outline hover:outline-1 hover:outline-muted-foreground focus:outline focus:outline-1 focus:outline-muted-foreground',
className,
{
'outline-red-500': isError,
@@ -1,36 +1,28 @@
import { useMemo, useState } from 'react';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import type { DropResult } from '@hello-pangea/dnd';
import { msg, plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
import { X } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { Link } from 'react-router';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import {
useCurrentEnvelopeEditor,
useDebounceFunction,
} from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { nanoid } from '@documenso/lib/universal/id';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { Button } from '@documenso/ui/primitives/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@documenso/ui/primitives/card';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { DropResult } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { msg, plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { FileWarningIcon, GripVerticalIcon, Loader2Icon, PencilIcon, XIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
@@ -42,17 +34,30 @@ type LocalFile = {
title: string;
envelopeItemId: string | null;
isUploading: boolean;
isReplacing: boolean;
isError: boolean;
};
export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
const { t, i18n } = useLingui();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
const {
envelope,
setLocalEnvelope,
editorFields,
editorConfig,
isEmbedded,
navigateToStep,
registerExternalFlush,
registerPendingMutation,
} = useCurrentEnvelopeEditor();
const { envelopeItems: uploadConfig } = editorConfig;
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
envelope.envelopeItems
.sort((a, b) => a.order - b.order)
@@ -61,10 +66,36 @@ export const EnvelopeEditorUploadPage = () => {
title: item.title,
envelopeItemId: item.id,
isUploading: false,
isReplacing: false,
isError: false,
})),
);
const replacingItemIdRef = useRef<string | null>(null);
const { open: openReplaceFilePicker, getInputProps: getReplaceInputProps } = useDropzone({
accept: { 'application/pdf': ['.pdf'] },
maxFiles: 1,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
multiple: false,
noClick: true,
noKeyboard: true,
noDrag: true,
onDrop: (acceptedFiles) => {
const file = acceptedFiles[0];
const replacingItemId = replacingItemIdRef.current;
if (file && replacingItemId) {
void onReplacePdf(replacingItemId, file);
replacingItemIdRef.current = null;
}
},
onDropRejected: (fileRejections) => void onFileDropRejected(fileRejections),
onFileDialogCancel: () => {
replacingItemIdRef.current = null;
},
});
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
trpc.envelope.item.createMany.useMutation({
onSuccess: ({ data }) => {
@@ -97,23 +128,71 @@ export const EnvelopeEditorUploadPage = () => {
},
});
const canItemsBeModified = useMemo(
() => canEnvelopeItemsBeModified(envelope, envelope.recipients),
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
[envelope, envelope.recipients],
);
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
onSuccess: ({ data, fields }) => {
// Update the envelope item with the new documentDataId.
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
item.id === data.id ? { ...item, documentDataId: data.documentDataId } : item,
),
});
// When fields were created or deleted during the replacement,
// the server returns the full updated field list.
if (fields) {
setLocalEnvelope({ fields });
editorFields.resetForm(fields);
}
},
});
const onFileDrop = async (files: File[]) => {
const newUploadingFiles: (LocalFile & { file: File })[] = files.map((file) => ({
id: nanoid(),
envelopeItemId: null,
title: file.name,
file,
isUploading: true,
isError: false,
}));
const newUploadingFiles: (LocalFile & {
file: File;
data: TEditorEnvelope['envelopeItems'][number]['data'] | null;
})[] = await Promise.all(
files.map(async (file) => {
return {
id: nanoid(),
envelopeItemId: isEmbedded ? `${PRESIGNED_ENVELOPE_ITEM_ID_PREFIX}${nanoid()}` : null,
title: file.name,
file,
isUploading: isEmbedded ? false : true,
isReplacing: false,
// Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once)
data: isEmbedded ? new Uint8Array((await file.arrayBuffer()).slice(0)) : null,
isError: false,
};
}),
);
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
// Directly commit the files for embedded documents since those are not uploaded
// until the end of the embedded flow.
if (isEmbedded) {
setLocalEnvelope({
envelopeItems: [
...envelope.envelopeItems,
...newUploadingFiles.map((file) => ({
id: file.envelopeItemId!,
title: file.title,
order: envelope.envelopeItems.length + 1,
envelopeId: envelope.id,
data: file.data!,
documentDataId: '',
})),
],
});
return;
}
const payload = {
envelopeId: envelope.id,
} satisfies TCreateEnvelopeItemsPayload;
@@ -126,7 +205,11 @@ export const EnvelopeEditorUploadPage = () => {
formData.append('files', file);
}
const { data } = await createEnvelopeItems(formData).catch((error) => {
const createPromise = createEnvelopeItems(formData);
registerPendingMutation(createPromise);
const { data } = await createPromise.catch((error) => {
console.error(error);
// Set error state on files in batch upload.
@@ -143,8 +226,7 @@ export const EnvelopeEditorUploadPage = () => {
setLocalFiles((prev) => {
const filteredFiles = prev.filter(
(uploadingFile) =>
uploadingFile.id !== newUploadingFiles.find((file) => file.id === uploadingFile.id)?.id,
(uploadingFile) => uploadingFile.id !== newUploadingFiles.find((file) => file.id === uploadingFile.id)?.id,
);
return filteredFiles.concat(
@@ -153,21 +235,80 @@ export const EnvelopeEditorUploadPage = () => {
envelopeItemId: item.id,
title: item.title,
isUploading: false,
isReplacing: false,
isError: false,
})),
);
});
};
const onReplacePdf = async (envelopeItemId: string, file: File) => {
setLocalFiles((prev) => prev.map((f) => (f.envelopeItemId === envelopeItemId ? { ...f, isReplacing: true } : f)));
try {
if (isEmbedded) {
// For embedded mode, store the file data locally on the envelope item.
// The actual replacement will happen when the embed flow submits.
const arrayBuffer = await file.arrayBuffer();
const data = new Uint8Array(arrayBuffer.slice(0));
// Count pages in the new PDF to remove out-of-bounds fields.
const { PDF } = await import('@libpdf/core');
const pdfDoc = await PDF.load(data);
const newPageCount = pdfDoc.getPageCount();
// Remove fields that are on pages beyond the new PDF's page count.
const remainingFields = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItemId || field.page <= newPageCount,
);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) => (item.id === envelopeItemId ? { ...item, data } : item)),
fields: remainingFields,
});
editorFields.resetForm(remainingFields);
return;
}
// Normal mode: upload immediately via tRPC.
const payload = {
envelopeId: envelope.id,
envelopeItemId,
} satisfies TReplaceEnvelopeItemPdfPayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const replacePromise = replaceEnvelopeItemPdf(formData);
registerPendingMutation(replacePromise);
await replacePromise;
} catch (error) {
console.error(error);
toast({
title: t`Replace failed`,
description: t`Something went wrong while replacing the PDF`,
duration: 5000,
variant: 'destructive',
});
} finally {
setLocalFiles((prev) =>
prev.map((f) => (f.envelopeItemId === envelopeItemId ? { ...f, isReplacing: false } : f)),
);
}
};
/**
* Hide the envelope item from the list on deletion.
*/
const onFileDelete = (envelopeItemId: string) => {
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.envelopeItemId !== envelopeItemId));
const fieldsWithoutDeletedItem = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItemId,
);
const fieldsWithoutDeletedItem = envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
@@ -194,18 +335,59 @@ export const EnvelopeEditorUploadPage = () => {
debouncedUpdateEnvelopeItems(items);
};
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
void updateEnvelopeItems({
envelopeId: envelope.id,
data: files
.filter((item) => item.envelopeItemId)
.map((item, index) => ({
envelopeItemId: item.envelopeItemId || '',
order: index + 1,
title: item.title,
})),
});
}, 1000);
const { triggerSave: debouncedUpdateEnvelopeItems, flush: flushUpdateEnvelopeItems } = useEnvelopeAutosave(
async (files: LocalFile[]) => {
if (isEmbedded) {
const nextEnvelopeItems = files
.filter((item) => item.envelopeItemId)
.map((item, index) => {
const originalEnvelopeItem = envelope.envelopeItems.find(
(envelopeItem) => envelopeItem.id === item.envelopeItemId,
);
return {
id: item.envelopeItemId || '',
title: item.title,
order: index + 1,
envelopeId: envelope.id,
data: originalEnvelopeItem?.data,
documentDataId: originalEnvelopeItem?.documentDataId || '',
};
});
setLocalEnvelope({
envelopeItems: nextEnvelopeItems,
});
return;
}
await updateEnvelopeItems({
envelopeId: envelope.id,
data: files
.filter((item) => item.envelopeItemId)
.map((item, index) => ({
envelopeItemId: item.envelopeItemId || '',
order: index + 1,
title: item.title,
})),
});
},
isEmbedded ? 0 : 1000,
);
const flushUpdateEnvelopeItemsRef = useRef(flushUpdateEnvelopeItems);
flushUpdateEnvelopeItemsRef.current = flushUpdateEnvelopeItems;
// Register the flush callback with the provider so flushAutosave can await
// pending envelope item mutations. We intentionally do NOT unregister on unmount
// because the upload page is unmounted (replaced with a spinner) before
// flushAutosave runs during step transitions. The hook's internal refs survive
// unmounting, so the flush callback remains valid.
useEffect(() => {
registerExternalFlush('envelopeItems', async () => flushUpdateEnvelopeItemsRef.current());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onEnvelopeItemTitleChange = (envelopeItemId: string, title: string) => {
const newLocalFilesValue = localFiles.map((uploadingFile) =>
@@ -217,7 +399,7 @@ export const EnvelopeEditorUploadPage = () => {
};
const dropzoneDisabledMessage = useMemo(() => {
if (!canItemsBeModified) {
if (!envelopeItemPermissions.canFileBeChanged) {
return msg`Cannot upload items after the document has been sent`;
}
@@ -258,7 +440,7 @@ export const EnvelopeEditorUploadPage = () => {
toast({
title: t`Upload failed`,
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
@@ -266,6 +448,7 @@ export const EnvelopeEditorUploadPage = () => {
return (
<div className="mx-auto max-w-4xl space-y-6 p-8">
<input {...getReplaceInputProps()} />
<Card backdropBlur={false} className="border">
<CardHeader className="pb-3">
<CardTitle>
@@ -277,32 +460,46 @@ export const EnvelopeEditorUploadPage = () => {
</CardHeader>
<CardContent>
<DocumentDropzone
onDrop={onFileDrop}
allowMultiple
className="pb-4 pt-6"
disabled={dropzoneDisabledMessage !== null}
disabledMessage={dropzoneDisabledMessage || undefined}
disabledHeading={msg`Upload disabled`}
maxFiles={maximumEnvelopeItemCount - localFiles.length}
onDropRejected={onFileDropRejected}
/>
{uploadConfig?.allowUpload && (
<DocumentDropzone
data-testid="envelope-item-dropzone"
onDrop={onFileDrop}
allowMultiple
className="pt-6 pb-4"
disabled={dropzoneDisabledMessage !== null}
disabledMessage={dropzoneDisabledMessage || undefined}
disabledHeading={msg`Upload disabled`}
maxFiles={maximumEnvelopeItemCount - localFiles.length}
onDropRejected={onFileDropRejected}
/>
)}
{/* Uploaded Files List */}
<div className="mt-4">
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="files">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
<div
data-testid="envelope-items-list"
{...provided.droppableProps}
ref={provided.innerRef}
className="space-y-2"
>
{localFiles.map((localFile, index) => (
<Draggable
key={localFile.id}
isDragDisabled={isCreatingEnvelopeItems || !canItemsBeModified}
isDragDisabled={
isCreatingEnvelopeItems ||
!envelopeItemPermissions.canOrderBeChanged ||
localFile.isReplacing ||
!uploadConfig?.allowConfigureOrder
}
draggableId={localFile.id}
index={index}
>
{(provided, snapshot) => (
<div
data-testid={`envelope-item-row-${localFile.id}`}
ref={provided.innerRef}
{...provided.draggableProps}
style={provided.draggableProps.style}
@@ -310,42 +507,49 @@ export const EnvelopeEditorUploadPage = () => {
snapshot.isDragging ? 'shadow-md' : ''
}`}
>
<div className="flex items-center space-x-3">
<div
{...provided.dragHandleProps}
className="cursor-grab active:cursor-grabbing"
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
</div>
<div className="flex min-w-0 items-center space-x-3">
{uploadConfig?.allowConfigureOrder && (
<div
{...provided.dragHandleProps}
data-testid={`envelope-item-drag-handle-${localFile.id}`}
className="cursor-grab active:cursor-grabbing"
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
</div>
)}
<div>
<div className="min-w-0">
{localFile.envelopeItemId !== null ? (
<EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT}
disabled={
!envelopeItemPermissions.canTitleBeChanged ||
!uploadConfig?.allowConfigureTitle ||
localFile.isReplacing
}
value={localFile.title}
dataTestId={`envelope-item-title-input-${localFile.id}`}
placeholder={t`Document Title`}
onChange={(title) => {
onEnvelopeItemTitleChange(localFile.envelopeItemId!, title);
}}
/>
) : (
<p className="text-sm font-medium">{localFile.title}</p>
<p className="font-medium text-sm">{localFile.title}</p>
)}
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
{localFile.isUploading ? (
<Trans>Uploading</Trans>
) : localFile.isError ? (
<Trans>Something went wrong while uploading this file</Trans>
) : // <div className="text-xs text-gray-500">2.4 MB • 3 pages</div>
null}
) : null}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex shrink-0 items-center space-x-2">
{localFile.isUploading && (
<div className="flex h-6 w-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
@@ -355,20 +559,58 @@ export const EnvelopeEditorUploadPage = () => {
</div>
)}
{!localFile.isUploading && localFile.envelopeItemId && (
<EnvelopeItemDeleteDialog
canItemBeDeleted={canItemsBeModified}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}
onDelete={onFileDelete}
trigger={
<Button variant="ghost" size="sm">
<X className="h-4 w-4" />
</Button>
}
/>
)}
{localFile.envelopeItemId &&
envelopeItemPermissions.canFileBeChanged &&
uploadConfig?.allowReplace && (
<Button
variant="ghost"
size="sm"
data-testid={`envelope-item-replace-button-${localFile.id}`}
disabled={localFile.isReplacing || localFile.isUploading}
onClick={() => {
replacingItemIdRef.current = localFile.envelopeItemId;
openReplaceFilePicker();
}}
>
{localFile.isReplacing ? (
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<PencilIcon className="h-4 w-4" />
)}
</Button>
)}
{localFile.envelopeItemId &&
uploadConfig?.allowDelete &&
(isEmbedded ? (
<Button
variant="ghost"
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
onClick={() => onFileDelete(localFile.envelopeItemId!)}
disabled={localFile.isReplacing || localFile.isUploading}
>
<XIcon className="h-4 w-4" />
</Button>
) : (
<EnvelopeItemDeleteDialog
canItemBeDeleted={envelopeItemPermissions.canFileBeChanged}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}
onDelete={onFileDelete}
trigger={
<Button
variant="ghost"
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
disabled={localFile.isReplacing || localFile.isUploading}
>
<XIcon className="h-4 w-4" />
</Button>
}
/>
))}
</div>
</div>
)}
@@ -386,13 +628,13 @@ export const EnvelopeEditorUploadPage = () => {
{/* Recipients Section */}
<EnvelopeEditorRecipientForm />
<div className="flex justify-end">
<Button asChild>
<Link to={`${relativePath.editorPath}?step=addFields`}>
{editorConfig.general.allowAddFieldsStep && (
<div className="flex justify-end">
<Button type="button" onClick={() => void navigateToStep('addFields')}>
<Trans>Add Fields</Trans>
</Link>
</Button>
</div>
</Button>
</div>
)}
</div>
);
};
@@ -1,40 +1,39 @@
import { useEffect, useState } from 'react';
import type { EnvelopeEditorStep } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { motion } from 'framer-motion';
import {
ArrowLeftIcon,
CopyPlusIcon,
DownloadCloudIcon,
EyeIcon,
FileOutputIcon,
LinkIcon,
MousePointer,
type LucideIcon,
MousePointerIcon,
SendIcon,
SettingsIcon,
Trash2Icon,
Upload,
UploadIcon,
} from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
mapSecondaryIdToDocumentId,
mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
@@ -43,52 +42,88 @@ import EnvelopeEditorHeader from './envelope-editor-header';
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
type EnvelopeEditorStepData = {
id: string;
title: MessageDescriptor;
icon: LucideIcon;
description: MessageDescriptor;
};
const envelopeEditorSteps = [
{
id: 'upload',
order: 1,
title: msg`Document & Recipients`,
icon: Upload,
description: msg`Upload documents and add recipients`,
},
{
id: 'addFields',
order: 2,
title: msg`Add Fields`,
icon: MousePointer,
description: msg`Place and configure form fields in the document`,
},
{
id: 'preview',
order: 3,
title: msg`Preview`,
icon: EyeIcon,
description: msg`Preview the document before sending`,
},
];
const UPLOAD_STEP = {
id: 'upload',
title: msg`Document & Recipients`,
icon: UploadIcon,
description: msg`Upload documents and add recipients`,
};
export default function EnvelopeEditor() {
const ADD_FIELDS_STEP = {
id: 'addFields',
title: msg`Add Fields`,
icon: MousePointerIcon,
description: msg`Place and configure form fields in the document`,
};
const PREVIEW_STEP = {
id: 'preview',
title: msg`Preview`,
icon: EyeIcon,
description: msg`Preview the document before sending`,
};
export const EnvelopeEditor = () => {
const { t } = useLingui();
const navigate = useNavigate();
const {
envelope,
editorConfig,
isDocument,
isTemplate,
isAutosaving,
flushAutosave,
relativePath,
navigateToStep,
syncEnvelope,
flushAutosave,
resetForms,
} = useCurrentEnvelopeEditor();
const [searchParams, setSearchParams] = useSearchParams();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isStepLoading, setIsStepLoading] = useState(false);
const [currentStep, setCurrentStep] = useState<EnvelopeEditorStep>(() => {
const {
general: { minimizeLeftSidebar, allowUploadAndRecipientStep, allowAddFieldsStep, allowPreviewStep },
actions: {
allowDistributing,
allowDirectLink,
allowDuplication,
allowSaveAsTemplate,
allowDownloadPDF,
allowDeletion,
},
} = editorConfig;
const envelopeEditorSteps = useMemo(() => {
const steps: EnvelopeEditorStepData[] = [];
if (allowUploadAndRecipientStep) {
steps.push(UPLOAD_STEP);
}
if (allowAddFieldsStep) {
steps.push(ADD_FIELDS_STEP);
}
if (allowPreviewStep) {
steps.push(PREVIEW_STEP);
}
return steps.map((step, index) => ({
...step,
order: index + 1,
}));
}, [editorConfig]);
const searchParamsStep = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined;
// Empty URL param equals upload, otherwise use the step URL param
@@ -103,31 +138,26 @@ export default function EnvelopeEditor() {
}
return 'upload';
});
}, [searchParams]);
const navigateToStep = (step: EnvelopeEditorStep) => {
setCurrentStep(step);
const [pageToRender, setPageToRender] = useState<EnvelopeEditorStep | 'loading'>(searchParamsStep);
void flushAutosave();
const latestStepChangeTime = useRef(0);
if (!isStepLoading && isAutosaving) {
setIsStepLoading(true);
}
const handleStepChange = async (step: EnvelopeEditorStep) => {
setPageToRender('loading');
// Update URL params: empty for upload, otherwise set the step
if (step === 'upload') {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.delete('step');
return newParams;
});
} else {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.set('step', step);
return newParams;
});
}
const currentTime = Date.now();
latestStepChangeTime.current = currentTime;
await flushAutosave().then(() => {
if (currentTime !== latestStepChangeTime.current) {
return;
}
resetForms();
setPageToRender(step);
});
};
// Watch the URL params and setStep if the step changes.
@@ -136,82 +166,134 @@ export default function EnvelopeEditor() {
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
if (foundStep && foundStep.id !== currentStep) {
if (foundStep && foundStep.id !== pageToRender) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
navigateToStep(foundStep.id as EnvelopeEditorStep);
void handleStepChange(foundStep.id as EnvelopeEditorStep);
}
}, [searchParams]);
useEffect(() => {
if (!isAutosaving) {
setIsStepLoading(false);
}
}, [isAutosaving]);
const currentStepData =
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
const currentStepData = envelopeEditorSteps.find((step) => step.id === searchParamsStep) || envelopeEditorSteps[0];
return (
<div className="h-screen w-screen bg-gray-50 dark:bg-background">
<div className="h-screen w-screen bg-envelope-editor-background">
<EnvelopeEditorHeader />
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4">
<div
className={cn('flex w-80 flex-shrink-0 flex-col overflow-y-auto border-border border-r bg-background py-4', {
'w-14': minimizeLeftSidebar,
})}
>
{/* Left section step selector. */}
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
/>
{minimizeLeftSidebar ? (
<div className="flex justify-center px-4">
<div className="relative flex h-10 w-10 items-center justify-center">
<svg className="size-10 -rotate-90" viewBox="0 0 40 40" aria-hidden>
{/* Track circle */}
<circle
cx="20"
cy="20"
r="16"
fill="none"
stroke="currentColor"
strokeWidth="3"
className="text-muted"
/>
{/* Progress arc */}
<motion.circle
cx="20"
cy="20"
r="16"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
className="text-primary"
strokeDasharray={2 * Math.PI * 16}
initial={false}
animate={{
strokeDashoffset:
2 * Math.PI * 16 * (1 - (currentStepData.order ?? 0) / envelopeEditorSteps.length),
}}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center font-semibold text-[10px] text-foreground">
<Trans context="The step counter">
{currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</div>
</div>
) : (
<div className="px-4">
<h3 className="flex items-end justify-between font-semibold text-foreground text-sm">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<div className="space-y-3">
{envelopeEditorSteps.map((step) => {
const Icon = step.icon;
const isActive = currentStep === step.id;
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-muted-foreground text-xs">
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
return (
<div
key={step.id}
className={`cursor-pointer rounded-lg p-3 transition-colors ${
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
/>
</div>
</div>
)}
<div
className={cn('space-y-3', {
'px-4': !minimizeLeftSidebar,
'mt-4 flex flex-col items-center': minimizeLeftSidebar,
})}
>
{envelopeEditorSteps.map((step) => {
const Icon = step.icon;
const isActive = searchParamsStep === step.id;
return (
<button
key={step.id}
data-testid={`envelope-editor-step-${step.id}`}
type="button"
className={cn(
`cursor-pointer rounded-lg text-left transition-colors ${
isActive
? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
}`}
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
>
<div className="flex items-center space-x-3">
<div
className={`rounded border p-2 ${
isActive
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
}`}
>
<Icon
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
/>
</div>
}`,
{
'p-3': !minimizeLeftSidebar,
},
)}
onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)}
>
<div className="flex items-center space-x-3">
<div
className={`rounded border p-2 ${
isActive
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
}`}
>
<Icon className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`} />
</div>
{!minimizeLeftSidebar && (
<div>
<div
className={`text-sm font-medium ${
className={`font-medium text-sm ${
isActive
? 'text-green-900 dark:text-green-400'
: 'text-foreground dark:text-muted-foreground'
@@ -219,61 +301,84 @@ export default function EnvelopeEditor() {
>
{t(step.title)}
</div>
<div className="text-xs text-muted-foreground">{t(step.description)}</div>
<div className="text-muted-foreground text-xs">{t(step.description)}</div>
</div>
</div>
)}
</div>
);
})}
</div>
</button>
);
})}
</div>
<Separator className="my-6" />
<Separator
className={cn('my-6', {
'mx-auto mb-4 w-4/5': minimizeLeftSidebar,
})}
/>
{/* Quick Actions. */}
<div className="space-y-3 px-4">
<h4 className="text-sm font-semibold text-foreground">
<Trans>Quick Actions</Trans>
</h4>
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SettingsIcon className="mr-2 h-4 w-4" />
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</Button>
}
/>
<div
className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
'px-2': minimizeLeftSidebar,
})}
>
{!minimizeLeftSidebar && (
<h4 className="font-semibold text-foreground text-sm">
<Trans>Quick Actions</Trans>
</h4>
)}
{isDocument && (
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
{editorConfig.settings && (
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Settings`)}>
<SettingsIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</span>
)}
</Button>
}
/>
)}
{isDocument && (
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
{isDocument && allowDistributing && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Send Envelope`)}>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Send Document</Trans>
</span>
)}
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Resend Envelope`)}>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Resend Document</Trans>
</span>
)}
</Button>
}
/>
</>
)}
{/* <Button variant="ghost" size="sm" className="w-full justify-start">
<FileText className="mr-2 h-4 w-4" />
Save as Template
</Button> */}
{isTemplate && (
{isTemplate && allowDirectLink && (
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
@@ -281,100 +386,153 @@ export default function EnvelopeEditor() {
onCreateSuccess={async () => await syncEnvelope()}
onDeleteSuccess={async () => await syncEnvelope()}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<LinkIcon className="mr-2 h-4 w-4" />
<Trans>Direct Link</Trans>
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Direct Link`)}>
<LinkIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Direct Link</Trans>
</span>
)}
</Button>
}
/>
)}
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={envelope.type}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<CopyPlusIcon className="mr-2 h-4 w-4" />
{isDocument ? (
<Trans>Duplicate Document</Trans>
) : (
<Trans>Duplicate Template</Trans>
)}
</Button>
}
/>
{allowDuplication && (
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={envelope.type}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Duplicate Envelope`)}>
<CopyPlusIcon className="h-4 w-4" />
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</Button>
}
/>
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
</span>
)}
</Button>
}
/>
)}
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2Icon className="mr-2 h-4 w-4" />
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
</Button>
{allowSaveAsTemplate && isDocument && (
<EnvelopeSaveAsTemplateDialog
envelopeId={envelope.id}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Save as Template`)}>
<FileOutputIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Save as Template</Trans>
</span>
)}
</Button>
}
/>
)}
{allowDownloadPDF && (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
isLegacy={envelope.internalVersion === 1}
envelopeItems={envelope.envelopeItems}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Download PDF`)}>
<DownloadCloudIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Download PDF</Trans>
</span>
)}
</Button>
}
/>
)}
{/* Check envelope ID since it can be in embedded create mode. */}
{allowDeletion && envelope.id && (
<EnvelopeDeleteDialog
id={envelope.id}
type={envelope.type}
status={envelope.status}
title={envelope.title}
canManageDocument={true}
trigger={
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Delete Envelope`)}
>
<Trash2Icon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
</span>
)}
</Button>
}
onDelete={async () => {
await navigate(
envelope.type === EnvelopeType.DOCUMENT
? relativePath.documentRootPath
: relativePath.templateRootPath,
);
}}
/>
)}
</div>
{isDocument ? (
<DocumentDeleteDialog
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
status={envelope.status}
documentTitle={envelope.title}
canManageDocument={true}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={async () => {
await navigate(relativePath.documentRootPath);
}}
/>
) : (
<TemplateDeleteDialog
id={mapSecondaryIdToTemplateId(envelope.secondaryId)}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={async () => {
await navigate(relativePath.templateRootPath);
}}
/>
)}
{/* Footer of left sidebar. */}
<div className="mt-auto px-4">
<Button variant="ghost" className="w-full justify-start" asChild>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="mr-2 h-4 w-4" />
{isDocument ? (
<Trans>Return to documents</Trans>
) : (
<Trans>Return to templates</Trans>
)}
</Link>
</Button>
</div>
{!editorConfig.embedded && (
<div
className={cn('mt-auto px-4', {
'px-2': minimizeLeftSidebar,
})}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'flex items-center justify-center': minimizeLeftSidebar,
})}
asChild
>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? <Trans>Return to documents</Trans> : <Trans>Return to templates</Trans>}
</span>
)}
</Link>
</Button>
</div>
)}
</div>
{/* Main Content - Changes based on current step */}
<AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}>
{match({ currentStep, isStepLoading })
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
.exhaustive()}
</AnimateGenericFadeInOut>
<div className="flex-1 overflow-y-auto">
{match({
pageToRender,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
})
.with({ pageToRender: 'loading' }, () => <SpinnerBox className="py-32" />)
.with({ pageToRender: 'upload', allowUploadAndRecipientStep: true }, () => <EnvelopeEditorUploadPage />)
.with({ pageToRender: 'addFields', allowAddFieldsStep: true }, () => <EnvelopeEditorFieldsPage />)
.with({ pageToRender: 'preview', allowPreviewStep: true }, () => <EnvelopeEditorPreviewPage />)
.otherwise(() => null)}
</div>
</div>
</div>
);
}
};
@@ -1,7 +1,6 @@
import { Plural } from '@lingui/react/macro';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { cn } from '@documenso/ui/lib/utils';
import { Plural } from '@lingui/react/macro';
type EnvelopeItemSelectorProps = {
number: number;
@@ -9,6 +8,7 @@ type EnvelopeItemSelectorProps = {
secondaryText: React.ReactNode;
isSelected: boolean;
buttonProps: React.ButtonHTMLAttributes<HTMLButtonElement>;
actionSlot?: React.ReactNode;
};
export const EnvelopeItemSelector = ({
@@ -17,11 +17,12 @@ export const EnvelopeItemSelector = ({
secondaryText,
isSelected,
buttonProps,
actionSlot,
}: EnvelopeItemSelectorProps) => {
return (
<button
title={typeof primaryText === 'string' ? primaryText : undefined}
className={`flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
className={`group flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
isSelected
? 'border-green-200 bg-green-50 text-green-900 dark:border-green-400/30 dark:bg-green-400/10 dark:text-green-400'
: 'border-border bg-muted/50 hover:bg-muted/70'
@@ -29,21 +30,23 @@ export const EnvelopeItemSelector = ({
{...buttonProps}
>
<div
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-medium ${
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full font-medium text-xs ${
isSelected ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-600'
}`}
>
{number}
</div>
<div className="min-w-0 text-left">
<div className="truncate text-sm font-medium">{primaryText}</div>
<div className="text-xs text-gray-500">{secondaryText}</div>
<div className="truncate font-medium text-sm">{primaryText}</div>
<div className="text-gray-500 text-xs">{secondaryText}</div>
</div>
<div
className={cn('h-2 w-2 flex-shrink-0 rounded-full', {
'bg-green-500': isSelected,
})}
></div>
{actionSlot ?? (
<div
className={cn('h-2 w-2 flex-shrink-0 rounded-full', {
'bg-green-500': isSelected,
})}
/>
)}
</button>
);
};
@@ -52,17 +55,19 @@ type EnvelopeRendererFileSelectorProps = {
fields: { envelopeItemId: string }[];
className?: string;
secondaryOverride?: React.ReactNode;
renderItemAction?: (item: { id: string; title: string }) => React.ReactNode;
};
export const EnvelopeRendererFileSelector = ({
fields,
className,
secondaryOverride,
renderItemAction,
}: EnvelopeRendererFileSelectorProps) => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
return (
<div className={cn('flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4', className)}>
<div className={cn('scrollbar-hidden flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4', className)}>
{envelopeItems.map((doc, i) => (
<EnvelopeItemSelector
key={doc.id}
@@ -81,6 +86,7 @@ export const EnvelopeRendererFileSelector = ({
buttonProps={{
onClick: () => setCurrentEnvelopeItem(doc.id),
}}
actionSlot={renderItemAction?.(doc)}
/>
))}
</div>
@@ -1,46 +1,44 @@
import { useEffect, useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
import type Konva from 'konva';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
type PageRenderData,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
import { useLingui } from '@lingui/react/macro';
import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
import type Konva from 'konva';
import { useEffect, useMemo } from 'react';
type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
};
export default function EnvelopeGenericPageRenderer() {
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { i18n } = useLingui();
const {
envelopeStatus,
currentEnvelopeItem,
fields,
signatures,
recipients,
getRecipientColorKey,
setRenderError,
overrideSettings,
} = useCurrentEnvelopeRender();
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
});
const signaturesByFieldId = useMemo(() => {
return new Map(signatures.map((signature) => [signature.fieldId, signature]));
}, [signatures]);
const { _className, scale } = pageContext;
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
}, pageData);
const { scale, pageNumber } = pageData;
const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
@@ -48,10 +46,7 @@ export default function EnvelopeGenericPageRenderer() {
}
return fields
.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
)
.filter((field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id)
.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
@@ -70,10 +65,9 @@ export default function EnvelopeGenericPageRenderer() {
})
.filter(
({ inserted, fieldMeta, recipient }) =>
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
fieldMeta?.readOnly,
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) || fieldMeta?.readOnly,
);
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
}, [fields, pageNumber, currentEnvelopeItem?.id, recipients, envelopeStatus]);
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
if (!pageLayer.current) {
@@ -83,6 +77,16 @@ export default function EnvelopeGenericPageRenderer() {
const fieldTranslations = getClientSideFieldTranslations(i18n);
// Look up an inserted signature for this field. If we don't have one (e.g.
// the signatures haven't been loaded, or the field hasn't been signed yet)
// fall back to a placeholder so the field still renders something.
const insertedSignature = signaturesByFieldId.get(field.id);
const signature = insertedSignature ?? {
signatureImageAsBase64: '',
typedSignature: fieldTranslations.SIGNATURE,
};
renderField({
scale,
pageLayer: pageLayer.current,
@@ -94,10 +98,7 @@ export default function EnvelopeGenericPageRenderer() {
positionX: Number(field.positionX),
positionY: Number(field.positionY),
fieldMeta: field.fieldMeta,
signature: {
signatureImageAsBase64: '',
typedSignature: fieldTranslations.SIGNATURE,
},
signature,
},
translations: fieldTranslations,
pageWidth: unscaledViewport.width,
@@ -139,10 +140,7 @@ export default function EnvelopeGenericPageRenderer() {
// If doesn't exist in localFields, destroy it since it's been deleted.
pageLayer.current.find('Group').forEach((group) => {
if (
group.name() === 'field-group' &&
!localPageFields.some((field) => field.id.toString() === group.id())
) {
if (group.name() === 'field-group' && !localPageFields.some((field) => field.id.toString() === group.id())) {
group.destroy();
}
});
@@ -153,18 +151,16 @@ export default function EnvelopeGenericPageRenderer() {
});
pageLayer.current.batchDraw();
}, [localPageFields]);
}, [localPageFields, signaturesByFieldId]);
if (!currentEnvelopeItem) {
return null;
}
return (
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
<>
{overrideSettings?.showRecipientTooltip &&
pageData.imageLoadingState === 'loaded' &&
localPageFields.map((field) => (
<EnvelopeRecipientFieldTooltip
key={field.id}
@@ -176,14 +172,6 @@ export default function EnvelopeGenericPageRenderer() {
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
</>
);
}
};
@@ -1,34 +1,26 @@
import { useCallback, useState } from 'react';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import { RecipientRole, SendStatus } from '@prisma/client';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { sortBy } from 'remeda';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import { RecipientRole, SendStatus } from '@prisma/client';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { useCallback, useState } from 'react';
import { sortBy } from 'remeda';
export interface EnvelopeRecipientSelectorProps {
className?: string;
selectedRecipient: Recipient | null;
onSelectedRecipientChange: (recipient: Recipient) => void;
recipients: Recipient[];
selectedRecipient: TEnvelopeRecipientLite | null;
onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
recipients: TEnvelopeRecipientLite[];
fields: Field[];
align?: 'center' | 'end' | 'start';
}
@@ -46,7 +38,7 @@ export const EnvelopeRecipientSelector = ({
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
(recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
@@ -59,19 +51,12 @@ export const EnvelopeRecipientSelector = ({
role="combobox"
className={cn(
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === selectedRecipient?.id),
0,
),
).comboxBoxTrigger,
getRecipientColorStyles(recipients.findIndex((r) => r.id === selectedRecipient?.id)).comboBoxTrigger,
className,
)}
>
{selectedRecipient && (
<span className="flex-1 truncate text-left">
{getRecipientLabel(selectedRecipient)}
</span>
<span className="flex-1 truncate text-left">{getRecipientLabel(selectedRecipient)}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
@@ -95,9 +80,9 @@ export const EnvelopeRecipientSelector = ({
interface EnvelopeRecipientSelectorCommandProps {
className?: string;
selectedRecipient: Recipient | null;
onSelectedRecipientChange: (recipient: Recipient) => void;
recipients: Recipient[];
selectedRecipient: TEnvelopeRecipientLite | null;
onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
recipients: TEnvelopeRecipientLite[];
fields: Field[];
placeholder?: string;
}
@@ -113,7 +98,7 @@ export const EnvelopeRecipientSelectorCommand = ({
const { t, i18n } = useLingui();
const recipientsByRole = useCallback(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
const recipientsByRole: Record<RecipientRole, TEnvelopeRecipientLite[]> = {
CC: [],
VIEWER: [],
SIGNER: [],
@@ -131,21 +116,14 @@ export const EnvelopeRecipientSelectorCommand = ({
const recipientsByRoleToDisplay = useCallback(() => {
return Object.entries(recipientsByRole())
.filter(
([role]) =>
role !== RecipientRole.CC &&
role !== RecipientRole.VIEWER &&
role !== RecipientRole.ASSISTANT,
([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER && role !== RecipientRole.ASSISTANT,
)
.map(
([role, roleRecipients]) =>
[
role,
sortBy(
roleRecipients,
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[(r) => r.id, 'asc'],
),
] as [RecipientRole, Recipient[]],
sortBy(roleRecipients, [(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'], [(r) => r.id, 'asc']),
] as [RecipientRole, TEnvelopeRecipientLite[]],
);
}, [recipientsByRole]);
@@ -160,15 +138,12 @@ export const EnvelopeRecipientSelectorCommand = ({
);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
(recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
return (
<Command
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
className={className}
>
<Command value={selectedRecipient ? selectedRecipient.id.toString() : undefined} className={className}>
<CommandInput placeholder={placeholder} />
<CommandEmpty>
@@ -179,15 +154,12 @@ export const EnvelopeRecipientSelectorCommand = ({
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="mb-1 ml-2 mt-2 text-xs font-medium text-muted-foreground">
<div className="mt-2 mb-1 ml-2 font-medium text-muted-foreground text-xs">
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
</div>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="px-4 pb-4 pt-2.5 text-center text-xs text-muted-foreground/80"
>
<div key={`${role}-empty`} className="px-4 pt-2.5 pb-4 text-center text-muted-foreground/80 text-xs">
<Trans>No recipients with this role</Trans>
</div>
)}
@@ -197,12 +169,7 @@ export const EnvelopeRecipientSelectorCommand = ({
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,
'cursor-not-allowed': isRecipientDisabled(recipient.id),
@@ -240,8 +207,7 @@ export const EnvelopeRecipientSelectorCommand = ({
<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.
This document has already been sent to this recipient. You can no longer edit this recipient.
</Trans>
</TooltipContent>
</Tooltip>
@@ -255,7 +221,7 @@ export const EnvelopeRecipientSelectorCommand = ({
);
};
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i18n: I18n) => {
const extractRecipientLabel = (recipient: TEnvelopeRecipientLite, recipients: TEnvelopeRecipientLite[], i18n: I18n) => {
if (recipient.name && recipient.email) {
return `${recipient.name} (${recipient.email})`;
}
@@ -1,13 +1,11 @@
import { useMemo } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { Plural, Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { useMemo } from 'react';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
@@ -53,10 +51,7 @@ export default function EnvelopeSignerForm() {
{assistantRecipients
.filter((r) => r.fields.length > 0)
.map((r) => (
<div
key={r.id}
className="relative flex flex-col gap-4 rounded-lg border border-border bg-widget p-4"
>
<div key={r.id} className="relative flex flex-col gap-4 rounded-lg border border-border bg-widget p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RadioGroupItem
@@ -75,10 +70,10 @@ export default function EnvelopeSignerForm() {
</span>
)}
</Label>
<p className="text-xs text-muted-foreground">{r.email}</p>
<p className="text-muted-foreground text-xs">{r.email}</p>
</div>
</div>
<div className="text-xs leading-[inherit] text-muted-foreground">
<div className="text-muted-foreground text-xs leading-[inherit]">
<Plural
value={assistantFields.filter((field) => field.recipientId === r.id).length}
one="# field"
@@ -1,9 +1,3 @@
import { Plural, Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { BanIcon, DownloadCloudIcon } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
@@ -14,6 +8,11 @@ import {
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { Separator } from '@documenso/ui/primitives/separator';
import { Plural, Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { BanIcon, DownloadCloudIcon } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
@@ -25,13 +24,12 @@ import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-
import { EnvelopeSignerCompleteDialog } from './envelope-signing-complete-dialog';
export const EnvelopeSignerHeader = () => {
const { envelopeData, envelope, recipientFieldsRemaining, recipient } =
useRequiredEnvelopeSigningContext();
const { envelopeData, envelope, recipientFieldsRemaining, recipient } = useRequiredEnvelopeSigningContext();
const isEmbedSigning = useEmbedSigningContext() !== null;
return (
<nav className="embed--DocumentWidgetHeader max-w-screen flex flex-row justify-between border-b border-border bg-background px-4 py-3 md:px-6">
<nav className="embed--DocumentWidgetHeader flex max-w-screen flex-row justify-between border-border border-b bg-background px-4 py-3 md:px-6">
{/* Left side - Logo and title */}
<div className="flex min-w-0 flex-1 items-center space-x-2 md:w-auto md:flex-none">
{!isEmbedSigning && (
@@ -51,19 +49,14 @@ export const EnvelopeSignerHeader = () => {
</Link>
)}
<h1
title={envelope.title}
className="min-w-0 truncate text-base font-semibold text-foreground md:hidden"
>
<h1 title={envelope.title} className="min-w-0 truncate font-semibold text-base text-foreground md:hidden">
{envelope.title}
</h1>
{!isEmbedSigning && <Separator orientation="vertical" className="hidden h-6 md:block" />}
<div className="hidden items-center space-x-2 md:flex">
<h1 className="whitespace-nowrap text-sm font-medium text-foreground">
{envelope.title}
</h1>
<h1 className="whitespace-nowrap font-medium text-foreground text-sm">{envelope.title}</h1>
<Badge>
{match(recipient.role)
@@ -78,12 +71,8 @@ export const EnvelopeSignerHeader = () => {
{/* Right side - Desktop content */}
<div className="hidden items-center space-x-2 lg:flex">
<p className="mr-2 flex-shrink-0 text-sm text-muted-foreground">
<Plural
one="1 Field Remaining"
other="# Fields Remaining"
value={recipientFieldsRemaining.length}
/>
<p className="mr-2 flex-shrink-0 text-muted-foreground text-sm">
<Plural one="1 Field Remaining" other="# Fields Remaining" value={recipientFieldsRemaining.length} />
</p>
<EnvelopeSignerCompleteDialog />
@@ -1,20 +1,8 @@
import { useEffect, useMemo } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import {
type Field,
FieldType,
type Recipient,
RecipientRole,
type Signature,
SigningStatus,
} from '@prisma/client';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { match } from 'ts-pattern';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
type PageRenderData,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { isBase64Image } from '@documenso/lib/constants/signatures';
@@ -29,8 +17,13 @@ import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { type Field, FieldType, type Recipient, RecipientRole, type Signature, SigningStatus } from '@prisma/client';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { useEffect, useMemo } from 'react';
import { match } from 'ts-pattern';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { handleCheckboxFieldClick } from '~/utils/field-signing/checkbox-field';
@@ -49,7 +42,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
};
export default function EnvelopeSignerPageRenderer() {
export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
@@ -77,17 +70,12 @@ export default function EnvelopeSignerPageRenderer() {
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
pageData,
);
const { _className, scale } = pageContext;
const { scale, pageNumber } = pageData;
const { envelope } = envelopeData;
@@ -99,10 +87,9 @@ export default function EnvelopeSignerPageRenderer() {
}
return fieldsToRender.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
);
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
}, [recipientFields, selectedAssistantRecipientFields, pageNumber, currentEnvelopeItem?.id]);
/**
* Returns fields that have been fully signed by other recipients for this specific
@@ -117,7 +104,7 @@ export default function EnvelopeSignerPageRenderer() {
return recipient.fields
.filter(
(field) =>
field.page === pageContext.pageNumber &&
field.page === pageNumber &&
field.envelopeItemId === currentEnvelopeItem?.id &&
(field.inserted || field.fieldMeta?.readOnly),
)
@@ -132,7 +119,7 @@ export default function EnvelopeSignerPageRenderer() {
},
}));
});
}, [envelope.recipients, pageContext.pageNumber]);
}, [envelope.recipients, pageNumber, currentEnvelopeItem?.id]);
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) {
@@ -142,13 +129,11 @@ export default function EnvelopeSignerPageRenderer() {
const fieldToRender = ZFullFieldSchema.parse(unparsedField);
let color: TRecipientColor = 'green';
if (fieldToRender.fieldMeta?.readOnly) {
color = 'readOnly';
} else if (showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender)) {
color = 'orange';
}
const color = fieldToRender.fieldMeta?.readOnly
? 'readOnly'
: showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender)
? 'orange'
: 'green';
const { fieldGroup } = renderField({
scale,
@@ -173,7 +158,9 @@ export default function EnvelopeSignerPageRenderer() {
const currentTarget = e.currentTarget as Konva.Group;
const target = e.target as Konva.Shape;
const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect();
const fieldRect = fieldGroup.findOne('.field-rect');
const fieldWidth = fieldRect ? fieldRect.width() : fieldGroup.width();
const fieldHeight = fieldRect ? fieldRect.height() : fieldGroup.height();
const foundField = localPageFields.find((f) => f.id === unparsedField.id);
const foundLoadingGroup = currentTarget.findOne('.loading-spinner-group');
@@ -201,8 +188,8 @@ export default function EnvelopeSignerPageRenderer() {
}
const loadingSpinnerGroup = createSpinner({
fieldWidth: fieldWidth / scale,
fieldHeight: fieldHeight / scale,
fieldWidth,
fieldHeight,
});
const parsedFoundField = ZFullFieldSchema.parse(foundField);
@@ -243,8 +230,7 @@ export default function EnvelopeSignerPageRenderer() {
fieldGroup.add(loadingSpinnerGroup);
// Uncheck the value if it's already pressed.
const value =
field.inserted && selectedRadioIndex === fieldCustomText ? null : selectedRadioIndex;
const value = field.inserted && selectedRadioIndex === fieldCustomText ? null : selectedRadioIndex;
void signField(field.id, {
type: FieldType.RADIO,
@@ -460,11 +446,7 @@ export default function EnvelopeSignerPageRenderer() {
}
};
const signField = async (
fieldId: number,
payload: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => {
const signField = async (fieldId: number, payload: TSignEnvelopeFieldValue, authOptions?: TRecipientActionAuth) => {
try {
const { inserted } = await signFieldInternal(fieldId, payload, authOptions);
@@ -534,14 +516,11 @@ export default function EnvelopeSignerPageRenderer() {
}
return (
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
<>
{showPendingFieldTooltip &&
recipientFieldsRemaining.length > 0 &&
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
recipientFieldsRemaining[0]?.page === pageContext.pageNumber && (
recipientFieldsRemaining[0]?.page === pageNumber && (
<EnvelopeFieldToolTip
key={recipientFieldsRemaining[0].id}
field={recipientFieldsRemaining[0]}
@@ -562,14 +541,6 @@ export default function EnvelopeSignerPageRenderer() {
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
</>
);
}
};
@@ -1,17 +1,16 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { useNavigate, useRevalidator, useSearchParams } from 'react-router';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_CONTENT_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { useMemo } from 'react';
import { useNavigate, useRevalidator, useSearchParams } from 'react-router';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
@@ -43,8 +42,7 @@ export const EnvelopeSignerCompleteDialog = () => {
const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
const { mutateAsync: completeDocument, isPending } =
trpc.recipient.completeDocumentWithToken.useMutation();
const { mutateAsync: completeDocument, isPending } = trpc.recipient.completeDocumentWithToken.useMutation();
const { mutateAsync: createDocumentFromDirectTemplate } =
trpc.template.createDocumentFromDirectTemplate.useMutation();
@@ -71,6 +69,14 @@ export const EnvelopeSignerCompleteDialog = () => {
if (fieldTooltip) {
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
// Tooltip not in DOM (page virtualized away) — signal the PDF viewer
// to scroll to the correct page via the data attribute.
const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR);
if (pdfContent) {
pdfContent.setAttribute('data-scroll-to-page', String(nextField.page));
}
}
},
isEnvelopeItemSwitch ? 150 : 50,
@@ -212,10 +218,12 @@ export const EnvelopeSignerCompleteDialog = () => {
return {
name:
recipient.name ||
fullName ||
recipient.fields.find((field) => field.type === FieldType.NAME)?.customText ||
'',
email:
recipient.email ||
email ||
recipient.fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
'',
};
@@ -231,20 +239,14 @@ export const EnvelopeSignerCompleteDialog = () => {
<DocumentSigningCompleteDialog
isSubmitting={isPending}
recipientPayload={recipientPayload}
onSignatureComplete={
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
}
onSignatureComplete={isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick}
documentTitle={envelope.title}
fields={recipientFieldsRemaining}
fieldsValidated={handleOnNextFieldClick}
recipient={recipient}
allowDictateNextSigner={Boolean(
nextRecipient && envelope.documentMeta.allowDictateNextSigner,
)}
allowDictateNextSigner={Boolean(nextRecipient && envelope.documentMeta.allowDictateNextSigner)}
disableNameInput={!isDirectTemplate && recipient.name !== ''}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined}
buttonSize="sm"
position="center"
/>
@@ -1,19 +1,3 @@
import { type ReactNode, useState } from 'react';
import { plural } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Loader } from 'lucide-react';
import {
ErrorCode as DropzoneErrorCode,
ErrorCode,
type FileRejection,
useDropzone,
} from 'react-dropzone';
import { Link, useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
@@ -25,8 +9,17 @@ import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Loader } from 'lucide-react';
import { type ReactNode, useState } from 'react';
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { Link, useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentTeam } from '~/providers/team';
@@ -36,12 +29,8 @@ export interface EnvelopeDropZoneWrapperProps {
className?: string;
}
export const EnvelopeDropZoneWrapper = ({
children,
type,
className,
}: EnvelopeDropZoneWrapperProps) => {
const { t } = useLingui();
export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeDropZoneWrapperProps) => {
const { t, i18n } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const { folderId } = useParams();
@@ -111,10 +100,7 @@ export const EnvelopeDropZoneWrapper = ({
});
}
const pathPrefix =
type === EnvelopeType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
const pathPrefix = type === EnvelopeType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
@@ -128,10 +114,7 @@ export const EnvelopeDropZoneWrapper = ({
AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => t`You have reached the limit of the number of files per envelope.`,
)
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`)
.otherwise(() => t`An error occurred during upload.`);
toast({
@@ -167,42 +150,9 @@ export const EnvelopeDropZoneWrapper = ({
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
if (!errors.length) {
return;
}
const errorNodes = errors.map((error, index) => (
<span key={index} className="block">
{match(error.code)
.with(ErrorCode.FileTooLarge, () => (
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
))
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
.with(ErrorCode.TooManyFiles, () => (
<Trans>Only one file can be uploaded at a time</Trans>
))
.otherwise(() => (
<Trans>Unknown error</Trans>
))}
</span>
));
const description = (
<>
<span className="font-medium">
<Trans>{file.name} couldn't be uploaded:</Trans>
</span>
{errorNodes}
</>
);
toast({
title: t`Upload failed`,
description,
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
@@ -226,24 +176,20 @@ export const EnvelopeDropZoneWrapper = ({
{children}
{isDragActive && (
<div className="fixed left-0 top-0 z-[9999] h-full w-full bg-muted/60 backdrop-blur-[4px]">
<div className="fixed top-0 left-0 z-[9999] h-full w-full bg-muted/60 backdrop-blur-[4px]">
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
<h2 className="text-2xl font-semibold text-foreground">
{type === EnvelopeType.DOCUMENT ? (
<Trans>Upload Document</Trans>
) : (
<Trans>Upload Template</Trans>
)}
<h2 className="font-semibold text-2xl text-foreground">
{type === EnvelopeType.DOCUMENT ? <Trans>Upload Document</Trans> : <Trans>Upload Template</Trans>}
</h2>
<p className="text-md mt-4 text-muted-foreground">
<p className="mt-4 text-md text-muted-foreground">
<Trans>Drag and drop your PDF file here</Trans>
</p>
{isUploadDisabled && IS_BILLING_ENABLED() && (
<Link
to={`/o/${organisation.url}/settings/billing`}
className="mt-4 text-sm text-amber-500 hover:underline dark:text-amber-400"
className="mt-4 text-amber-500 text-sm hover:underline dark:text-amber-400"
>
<Trans>Upgrade your plan to upload more documents</Trans>
</Link>
@@ -253,7 +199,7 @@ export const EnvelopeDropZoneWrapper = ({
team?.id === undefined &&
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
<p className="mt-4 text-sm text-muted-foreground/80">
<p className="mt-4 text-muted-foreground/80 text-sm">
<Trans>
{remaining.documents} of {quota.documents} documents remaining this month.
</Trans>
@@ -1,31 +1,23 @@
import { useMemo, useState } from 'react';
import { msg, plural } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentUploadButton } from '@documenso/ui/primitives/document-upload-button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg, plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useMemo, useState } from 'react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentTeam } from '~/providers/team';
@@ -39,7 +31,7 @@ export type EnvelopeUploadButtonProps = {
* Upload an envelope
*/
export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUploadButtonProps) => {
const { t } = useLingui();
const { t, i18n } = useLingui();
const { toast } = useToast();
const { user } = useSession();
@@ -48,9 +40,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
const navigate = useNavigate();
const organisation = useCurrentOrganisation();
const userTimezone = TIME_ZONES.find(
(timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone,
);
const userTimezone = TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone);
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
@@ -103,10 +93,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
void refreshLimits();
const pathPrefix =
type === EnvelopeType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
const pathPrefix = type === EnvelopeType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
@@ -131,10 +118,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => t`You have reached the limit of the number of files per envelope.`,
)
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`)
.otherwise(() => t`An error occurred while uploading your document.`);
toast({
@@ -168,7 +152,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
toast({
title: t`Upload failed`,
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
@@ -193,17 +177,15 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
</div>
</TooltipTrigger>
{type === EnvelopeType.DOCUMENT &&
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
<TooltipContent>
<p className="text-sm">
<Trans>
{remaining.documents} of {quota.documents} documents remaining this month.
</Trans>
</p>
</TooltipContent>
)}
{type === EnvelopeType.DOCUMENT && remaining.documents > 0 && Number.isFinite(remaining.documents) && (
<TooltipContent>
<p className="text-sm">
<Trans>
{remaining.documents} of {quota.documents} documents remaining this month.
</Trans>
</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
@@ -1,3 +1,15 @@
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { Plural, Trans } from '@lingui/react/macro';
import { FolderType } from '@prisma/client';
import {
@@ -11,19 +23,6 @@ import {
} from 'lucide-react';
import { Link } from 'react-router';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useCurrentTeam } from '~/providers/team';
export type FolderCardProps = {
@@ -40,9 +39,7 @@ export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardP
const formatPath = () => {
const rootPath =
folder.type === FolderType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
folder.type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
return `${rootPath}/f/${folder.id}`;
};
@@ -58,19 +55,19 @@ export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardP
return (
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
<Card className="h-full border border-border transition-all hover:bg-muted/50">
<CardContent className="p-4">
<div className="flex min-w-0 items-center gap-3">
<FolderIcon className="text-documenso h-6 w-6 flex-shrink-0" />
<FolderIcon className="h-6 w-6 flex-shrink-0 text-documenso" />
<div className="flex w-full min-w-0 items-center justify-between">
<div className="min-w-0 flex-1">
<h3 className="flex min-w-0 items-center gap-2 font-medium">
<span className="truncate">{folder.name}</span>
{folder.pinned && <PinIcon className="text-documenso h-3 w-3 flex-shrink-0" />}
{folder.pinned && <PinIcon className="h-3 w-3 flex-shrink-0 text-documenso" />}
</h3>
<div className="text-muted-foreground mt-1 flex space-x-2 truncate text-xs">
<div className="mt-1 flex space-x-2 truncate text-muted-foreground text-xs">
<span>
{folder.type === FolderType.TEMPLATE ? (
<Plural
@@ -99,12 +96,7 @@ export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardP
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
data-testid="folder-card-more-button"
>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" data-testid="folder-card-more-button">
<MoreVerticalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -143,17 +135,17 @@ export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardP
export const FolderCardEmpty = ({ type }: { type: FolderType }) => {
return (
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
<Card className="h-full border border-border transition-all hover:bg-muted/50">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<FolderPlusIcon className="text-muted-foreground/60 h-6 w-6" />
<FolderPlusIcon className="h-6 w-6 text-muted-foreground/60" />
<div>
<h3 className="text-muted-foreground flex items-center gap-2 font-medium">
<h3 className="flex items-center gap-2 font-medium text-muted-foreground">
<Trans>Create folder</Trans>
</h3>
<div className="text-muted-foreground/60 mt-1 flex space-x-2 truncate text-xs">
<div className="mt-1 flex space-x-2 truncate text-muted-foreground/60 text-xs">
{type === FolderType.DOCUMENT ? (
<Trans>Organise your documents</Trans>
) : (
@@ -1,15 +1,13 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { FolderType } from '@prisma/client';
import { FolderIcon, HomeIcon } from 'lucide-react';
import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { Trans } from '@lingui/react/macro';
import { FolderType } from '@prisma/client';
import { FolderIcon, HomeIcon } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
@@ -43,23 +41,23 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
});
const formatBreadCrumbPath = (folderId: string) => {
const rootPath =
type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
const rootPath = type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
return `${rootPath}/f/${folderId}`;
};
const formatViewAllFoldersPath = () => {
const rootPath =
type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
const rootPath = type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
if (parentId) {
return `${rootPath}/folders?parentId=${parentId}`;
}
return `${rootPath}/folders`;
};
const formatRootPath = () => {
return type === FolderType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
return type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
};
const pinnedFolders = foldersData?.folders.filter((folder) => folder.pinned) || [];
@@ -69,7 +67,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
<div>
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div
className="flex flex-1 items-center text-sm font-medium text-muted-foreground hover:text-muted-foreground/80"
className="flex flex-1 items-center font-medium text-muted-foreground text-sm hover:text-muted-foreground/80"
data-testid="folder-grid-breadcrumbs"
>
<Link to={formatRootPath()} className="flex items-center">
@@ -100,9 +98,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
{/* If you delete this, delete the component as well. */}
{organisation.organisationClaim.flags.allowLegacyEnvelopes && (
<DocumentUploadButtonLegacy type={type} />
)}
{organisation.organisationClaim.flags.allowLegacyEnvelopes && <DocumentUploadButtonLegacy type={type} />}
<FolderCreateDialog type={type} />
</div>
@@ -189,13 +185,13 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
</div>
)}
{foldersData.folders.length > 12 && (
{unpinnedFolders.length > 12 && (
<div className="mt-2 flex items-center justify-center">
<Link
className="text-sm font-medium text-muted-foreground hover:text-foreground"
className="font-medium text-muted-foreground text-sm hover:text-foreground"
to={formatViewAllFoldersPath()}
>
View all folders
<Trans>View all folders</Trans>
</Link>
</div>
)}
@@ -1,3 +1,5 @@
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Button } from '@documenso/ui/primitives/button';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -6,9 +8,6 @@ import { motion } from 'framer-motion';
import { ChevronLeft } from 'lucide-react';
import { Link, useNavigate } from 'react-router';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Button } from '@documenso/ui/primitives/button';
type ErrorCodeMap = Record<
number,
{ subHeading: MessageDescriptor; heading: MessageDescriptor; message: MessageDescriptor }
@@ -56,8 +55,7 @@ export const GenericErrorLayout = ({
const navigate = useNavigate();
const { _ } = useLingui();
const { subHeading, heading, message } =
errorCodeMap[errorCode || 500] ?? defaultErrorCodeMap[500];
const { subHeading, heading, message } = errorCodeMap[errorCode || 500] ?? defaultErrorCodeMap[500];
return (
<div className="fixed inset-0 z-0 flex h-screen w-screen items-center justify-center">
@@ -70,7 +68,7 @@ export const GenericErrorLayout = ({
<img
src={backgroundPattern}
alt="background pattern"
className="-ml-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%] dark:contrast-[70%] dark:invert dark:sepia"
className="-mt-[15vh] -ml-[50vw] h-full scale-100 object-cover md:scale-100 lg:scale-[100%] dark:contrast-[70%] dark:invert dark:sepia"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
@@ -81,11 +79,11 @@ export const GenericErrorLayout = ({
<div className="inset-0 mx-auto flex h-full flex-grow items-center justify-center px-6 py-32">
<div>
<p className="text-muted-foreground font-semibold">{_(subHeading)}</p>
<p className="font-semibold text-muted-foreground">{_(subHeading)}</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">{_(heading)}</h1>
<h1 className="mt-3 font-bold text-2xl md:text-3xl">{_(heading)}</h1>
<p className="text-muted-foreground mt-4 text-sm">{_(message)}</p>
<p className="mt-4 text-muted-foreground text-sm">{_(message)}</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
{secondaryButton ||
@@ -1,14 +1,13 @@
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertCircle } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type LegacyFieldWarningPopoverProps = {
type?: 'document' | 'template';
documentId?: number;
@@ -25,10 +24,8 @@ export const LegacyFieldWarningPopover = ({
const revalidator = useRevalidator();
const { mutateAsync: updateTemplate, isPending: isUpdatingTemplate } =
trpc.template.updateTemplate.useMutation();
const { mutateAsync: updateDocument, isPending: isUpdatingDocument } =
trpc.document.update.useMutation();
const { mutateAsync: updateTemplate, isPending: isUpdatingTemplate } = trpc.template.updateTemplate.useMutation();
const { mutateAsync: updateDocument, isPending: isUpdatingDocument } = trpc.document.update.useMutation();
const onUpdateFieldsClick = async () => {
if (type === 'document') {
@@ -61,9 +58,7 @@ export const LegacyFieldWarningPopover = ({
toast({
title: _(msg`Fields updated`),
description: _(
msg`The fields have been updated to the new field insertion method successfully`,
),
description: _(msg`The fields have been updated to the new field insertion method successfully`),
});
};
@@ -86,13 +81,13 @@ export const LegacyFieldWarningPopover = ({
<p className="text-muted-foreground text-sm">
{type === 'document' ? (
<Trans>
This document is using legacy field insertion, we recommend using the new field
insertion method for more accurate results.
This document is using legacy field insertion, we recommend using the new field insertion method for more
accurate results.
</Trans>
) : (
<Trans>
This template is using legacy field insertion, we recommend using the new field
insertion method for more accurate results.
This template is using legacy field insertion, we recommend using the new field insertion method for more
accurate results.
</Trans>
)}
</p>
@@ -1,11 +1,3 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ChevronsUpDown, Plus } from 'lucide-react';
import { Link } from 'react-router';
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
@@ -22,6 +14,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ChevronsUpDown, Plus } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
export const MenuSwitcher = () => {
const { _ } = useLingui();
@@ -53,24 +51,15 @@ export const MenuSwitcher = () => {
avatarFallback={formatAvatarFallback(user.name || user.email)}
primaryText={user.name}
secondaryText={_(msg`Personal Account`)}
rightSideComponent={
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
}
rightSideComponent={<ChevronsUpDown className="ml-auto h-4 w-4 text-muted-foreground" />}
textSectionClassName="hidden lg:flex"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className={cn('z-[60] ml-6 w-full min-w-[12rem] md:ml-0')}
align="end"
forceMount
>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link
to="/settings/organisations?action=add-organisation"
className="flex items-center justify-between"
>
<DropdownMenuContent className={cn('z-[60] ml-6 w-full min-w-[12rem] md:ml-0')} align="end" forceMount>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/organisations?action=add-organisation" className="flex items-center justify-between">
<Trans>Create Organisation</Trans>
<Plus className="ml-2 h-4 w-4" />
</Link>
@@ -78,34 +67,31 @@ export const MenuSwitcher = () => {
<DropdownMenuSeparator />
{isUserAdmin && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/admin">
<Trans>Admin panel</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/profile">
<Trans>User settings</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-muted-foreground px-4 py-2"
onClick={() => setLanguageSwitcherOpen(true)}
>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" onClick={() => setLanguageSwitcherOpen(true)}>
<Trans>Language</Trans>
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive/90 hover:!text-destructive px-4 py-2"
className="hover:!text-destructive px-4 py-2 text-destructive/90"
onSelect={async () => authClient.signOut()}
>
<Trans>Sign Out</Trans>
@@ -1,6 +1,5 @@
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
export type CardMetricProps = {
icon?: LucideIcon;
@@ -18,7 +17,7 @@ export const CardMetric = ({ icon: Icon, title, value, className, children }: Ca
className,
)}
>
<div className="flex h-full max-h-full flex-col px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
<div className="flex h-full max-h-full flex-col px-4 pt-4 pb-6 sm:px-4 sm:pt-4 sm:pb-8">
<div className="flex items-start">
{Icon && (
<div className="mr-2 h-4 w-4">
@@ -26,13 +25,11 @@ export const CardMetric = ({ icon: Icon, title, value, className, children }: Ca
</div>
)}
<h3 className="text-primary-forground mb-2 flex items-end text-sm font-medium leading-tight">
{title}
</h3>
<h3 className="mb-2 flex items-end font-medium text-primary-forground text-sm leading-tight">{title}</h3>
</div>
{children || (
<p className="mt-auto text-4xl font-semibold leading-8 text-foreground">
<p className="mt-auto font-semibold text-4xl text-foreground leading-8">
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
)}
@@ -1,19 +1,11 @@
import * as React from 'react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Trans } from '@lingui/react/macro';
import { Role } from '@prisma/client';
import { Check, ChevronsUpDown } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import * as React from 'react';
type ComboboxProps = {
listValues: string[];
@@ -47,12 +39,7 @@ const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
<Button variant="outline" role="combobox" aria-expanded={open} className="w-[200px] justify-between">
{selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -66,12 +53,7 @@ const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
<CommandGroup>
{allRoles.map((value: string, i: number) => (
<CommandItem key={i} onSelect={() => handleSelect(value)}>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
)}
/>
<Check className={cn('mr-2 h-4 w-4', selectedValues.includes(value) ? 'opacity-100' : 'opacity-0')} />
{value}
</CommandItem>
))}
@@ -1,18 +1,3 @@
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import {
Building2Icon,
ChevronsUpDown,
Plus,
Settings2Icon,
SettingsIcon,
UsersIcon,
} from 'lucide-react';
import { Link, useLocation } from 'react-router';
import { authClient } from '@documenso/auth/client';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
@@ -34,6 +19,12 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Building2Icon, ChevronsUpDown, Plus, Settings2Icon, SettingsIcon, UsersIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router';
import { useOptionalCurrentTeam } from '~/providers/team';
@@ -59,9 +50,7 @@ export const OrgMenuSwitcher = () => {
};
const selectedOrg = organisations.find((org) => isPathOrgUrl(org.url));
const hoveredOrg = organisations.find(
(org) => org.id === hoveredOrgId || organisations.length === 1,
);
const hoveredOrg = organisations.find((org) => org.id === hoveredOrgId || organisations.length === 1);
const currentOrganisation = useOptionalCurrentOrganisation();
const currentTeam = useOptionalCurrentTeam();
@@ -94,9 +83,7 @@ export const OrgMenuSwitcher = () => {
avatarSrc: formatAvatarUrl(currentOrganisation.avatarImageId),
avatarFallback: formatAvatarFallback(currentOrganisation.name),
primaryText: currentOrganisation.name,
secondaryText: _(
EXTENDED_ORGANISATION_MEMBER_ROLE_MAP[currentOrganisation.currentOrganisationRole],
),
secondaryText: _(EXTENDED_ORGANISATION_MEMBER_ROLE_MAP[currentOrganisation.currentOrganisationRole]),
};
}
@@ -129,18 +116,14 @@ export const OrgMenuSwitcher = () => {
avatarFallback={dropdownMenuAvatarText.avatarFallback}
primaryText={dropdownMenuAvatarText.primaryText}
secondaryText={dropdownMenuAvatarText.secondaryText}
rightSideComponent={
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
}
rightSideComponent={<ChevronsUpDown className="ml-auto h-4 w-4 text-muted-foreground" />}
textSectionClassName="hidden lg:flex"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className={cn(
'divide-border z-[60] ml-6 flex w-full divide-x p-0 md:ml-0 md:min-w-[40rem]',
)}
className={cn('z-[60] ml-6 flex w-full divide-x divide-border p-0 md:ml-0 md:min-w-[40rem]')}
align="end"
forceMount
>
@@ -148,21 +131,17 @@ export const OrgMenuSwitcher = () => {
{/* Organisations column */}
<div className="flex w-full flex-col md:w-1/3">
<div className="flex h-12 items-center border-b p-2">
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
<h3 className="flex items-center px-2 font-medium text-muted-foreground text-sm">
<Building2Icon className="mr-2 h-3.5 w-3.5" />
<Trans>Organisations</Trans>
</h3>
</div>
<div className="flex-1 space-y-1 overflow-y-auto p-1.5">
{organisations.map((org) => (
<div
className="group relative"
key={org.id}
onMouseEnter={() => setHoveredOrgId(org.id)}
>
<div className="group relative" key={org.id} onMouseEnter={() => setHoveredOrgId(org.id)}>
<DropdownMenuItem
className={cn(
'text-muted-foreground w-full px-4 py-2',
'w-full px-4 py-2 text-muted-foreground',
org.id === currentOrganisation?.id && !hoveredOrgId && 'bg-accent',
org.id === hoveredOrgId && 'bg-accent',
)}
@@ -179,14 +158,11 @@ export const OrgMenuSwitcher = () => {
</Link>
</DropdownMenuItem>
{canExecuteOrganisationAction(
'MANAGE_ORGANISATION',
org.currentOrganisationRole,
) && (
<div className="absolute bottom-0 right-0 top-0 flex items-center justify-center">
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
<Link
to={`/o/${org.url}/settings`}
className="text-muted-foreground mr-2 rounded-sm border p-1 transition-opacity duration-200 group-hover:opacity-100 md:opacity-0"
className="mr-2 rounded-sm border p-1 text-muted-foreground transition-opacity duration-200 group-hover:opacity-100 md:opacity-0"
>
<Settings2Icon className="h-3.5 w-3.5" />
</Link>
@@ -207,7 +183,7 @@ export const OrgMenuSwitcher = () => {
{/* Teams column */}
<div className="hidden w-1/3 flex-col md:flex">
<div className="flex h-12 items-center border-b p-2">
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
<h3 className="flex items-center px-2 font-medium text-muted-foreground text-sm">
<UsersIcon className="mr-2 h-3.5 w-3.5" />
<Trans>Teams</Trans>
</h3>
@@ -219,7 +195,7 @@ export const OrgMenuSwitcher = () => {
<div className="group relative" key={team.id}>
<DropdownMenuItem
className={cn(
'text-muted-foreground w-full px-4 py-2',
'w-full px-4 py-2 text-muted-foreground',
team.id === currentTeam?.id && 'bg-accent',
)}
asChild
@@ -236,10 +212,10 @@ export const OrgMenuSwitcher = () => {
</DropdownMenuItem>
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
<div className="absolute bottom-0 right-0 top-0 flex items-center justify-center">
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
<Link
to={`/t/${team.url}/settings`}
className="text-muted-foreground mr-2 rounded-sm border p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
className="mr-2 rounded-sm border p-1 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100"
>
<Settings2Icon className="h-3.5 w-3.5" />
</Link>
@@ -248,7 +224,7 @@ export const OrgMenuSwitcher = () => {
</div>
))
) : (
<div className="text-muted-foreground my-12 flex items-center justify-center px-2 text-center text-sm">
<div className="my-12 flex items-center justify-center px-2 text-center text-muted-foreground text-sm">
<Trans>Select an organisation to view teams</Trans>
</div>
)}
@@ -268,14 +244,14 @@ export const OrgMenuSwitcher = () => {
{/* Settings column */}
<div className="hidden w-1/3 flex-col md:flex">
<div className="flex h-12 items-center border-b p-2">
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
<h3 className="flex items-center px-2 font-medium text-muted-foreground text-sm">
<SettingsIcon className="mr-2 h-3.5 w-3.5" />
<Trans>Settings</Trans>
</h3>
</div>
<div className="flex-1 overflow-y-auto p-1.5">
{isUserAdmin && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/admin">
<Trans>Admin panel</Trans>
</Link>
@@ -283,11 +259,8 @@ export const OrgMenuSwitcher = () => {
)}
{currentOrganisation &&
canExecuteOrganisationAction(
'MANAGE_ORGANISATION',
currentOrganisation.currentOrganisationRole,
) && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
canExecuteOrganisationAction('MANAGE_ORGANISATION', currentOrganisation.currentOrganisationRole) && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to={`/o/${currentOrganisation.url}/settings`}>
<Trans>Organisation settings</Trans>
</Link>
@@ -295,34 +268,34 @@ export const OrgMenuSwitcher = () => {
)}
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to={`/t/${currentTeam.url}/settings`}>
<Trans>Team settings</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/profile">
<Trans>Account</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-muted-foreground px-4 py-2"
className="px-4 py-2 text-muted-foreground"
onClick={() => setLanguageSwitcherOpen(true)}
>
<Trans>Language</Trans>
</DropdownMenuItem>
{currentOrganisation && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link
to={{
pathname: `/o/${currentOrganisation.url}/support`,
@@ -335,7 +308,7 @@ export const OrgMenuSwitcher = () => {
)}
<DropdownMenuItem
className="text-muted-foreground hover:!text-muted-foreground px-4 py-2"
className="hover:!text-muted-foreground px-4 py-2 text-muted-foreground"
onSelect={async () => authClient.signOut()}
>
<Trans>Sign Out</Trans>
@@ -0,0 +1,114 @@
import { trpc } from '@documenso/trpc/react';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationGroupType } from '@prisma/client';
export type OrganisationGroupOption = {
/** Organisation group ID. */
id: string;
name: string;
};
type OrganisationGroupsMultiSelectComboboxProps = {
organisationId: string;
/**
* Currently selected groups. Must include name so chips render with
* proper labels even before the first server search returns results.
*/
selectedGroups: OrganisationGroupOption[];
onChange: (groups: OrganisationGroupOption[]) => void;
/**
* If set, organisation groups already attached to this team are filtered
* out of the search results server-side. Used by "add groups to team" flows.
*/
excludeTeamId?: number;
/**
* Restrict search to specific group types. Defaults to CUSTOM groups only,
* matching how groups are managed in the organisation settings UI.
*/
types?: OrganisationGroupType[];
/** Number of groups to fetch per search call. Defaults to the schema cap (100). */
perPage?: number;
className?: string;
dataTestId?: string;
};
const toOption = (group: OrganisationGroupOption): Option => ({
value: group.id,
label: group.name,
groupName: group.name,
});
const fromOption = (option: Option): OrganisationGroupOption => ({
id: option.value,
name: typeof option.groupName === 'string' ? option.groupName : option.label,
});
/**
* Searchable multi-select combobox for picking organisation groups,
* backed by `trpc.organisation.group.find` with server-side search.
*
* Renders selected groups as chips and supports an unbounded number of
* organisation groups (paged out via debounced server queries).
*/
export const OrganisationGroupsMultiSelectCombobox = ({
organisationId,
selectedGroups,
onChange,
excludeTeamId,
types = [OrganisationGroupType.CUSTOM],
perPage = 100,
className,
dataTestId,
}: OrganisationGroupsMultiSelectComboboxProps) => {
const { _ } = useLingui();
const utils = trpc.useUtils();
const handleSearch = async (query: string): Promise<Option[]> => {
const result = await utils.organisation.group.find.fetch({
organisationId,
query,
page: 1,
perPage,
types,
excludeTeamId,
});
return result.data.map((group) =>
toOption({
id: group.id,
name: group.name ?? '',
}),
);
};
return (
<MultiSelect
className={className}
data-testid={dataTestId}
commandProps={{ label: _(msg`Select groups`) }}
inputProps={{ 'aria-label': _(msg`Select groups`) }}
placeholder={_(msg`Search groups by name`)}
value={selectedGroups.map(toOption)}
onChange={(options) => onChange(options.map(fromOption))}
onSearch={handleSearch}
triggerSearchOnFocus
hideClearAllButton
hidePlaceholderWhenSelected
delay={300}
loadingIndicator={
<p className="py-4 text-center text-muted-foreground text-sm">
<Trans>Loading...</Trans>
</p>
}
emptyIndicator={
<p className="py-4 text-center text-muted-foreground text-sm">
<Trans>No groups found</Trans>
</p>
}
/>
);
};
@@ -0,0 +1,111 @@
import { trpc } from '@documenso/trpc/react';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
export type OrganisationMemberOption = {
/** Organisation member ID. */
id: string;
name: string;
email: string;
};
type OrganisationMembersMultiSelectComboboxProps = {
organisationId: string;
/**
* Currently selected members. Must include name/email so chips render with
* proper labels even before the first server search returns results.
*/
selectedMembers: OrganisationMemberOption[];
onChange: (members: OrganisationMemberOption[]) => void;
/**
* If set, organisation members already on this team are filtered out of the
* search results server-side. Used by "add members to team" flows.
*/
excludeTeamId?: number;
/** Number of members to fetch per search call. Defaults to the schema cap (100). */
perPage?: number;
className?: string;
dataTestId?: string;
};
const toOption = (member: OrganisationMemberOption): Option => ({
value: member.id,
label: member.name ? `${member.name} (${member.email})` : member.email,
// Stash these so we can reconstruct OrganisationMemberOption on change.
email: member.email,
name: member.name,
});
const fromOption = (option: Option): OrganisationMemberOption => ({
id: option.value,
name: typeof option.name === 'string' ? option.name : '',
email: typeof option.email === 'string' ? option.email : '',
});
/**
* Searchable multi-select combobox for picking organisation members,
* backed by `trpc.organisation.member.find` with server-side search.
*
* Renders selected members as chips and supports an unbounded number of
* organisation members (paged out via debounced server queries).
*/
export const OrganisationMembersMultiSelectCombobox = ({
organisationId,
selectedMembers,
onChange,
excludeTeamId,
perPage = 100,
className,
dataTestId,
}: OrganisationMembersMultiSelectComboboxProps) => {
const { _ } = useLingui();
const utils = trpc.useUtils();
const handleSearch = async (query: string): Promise<Option[]> => {
const result = await utils.organisation.member.find.fetch({
organisationId,
query,
page: 1,
perPage,
excludeTeamId,
});
return result.data.map((member) =>
toOption({
id: member.id,
name: member.name,
email: member.email,
}),
);
};
return (
<MultiSelect
className={className}
data-testid={dataTestId}
commandProps={{ label: _(msg`Select members`) }}
inputProps={{ 'aria-label': _(msg`Select members`) }}
placeholder={_(msg`Search members by name or email`)}
value={selectedMembers.map(toOption)}
onChange={(options) => onChange(options.map(fromOption))}
onSearch={handleSearch}
triggerSearchOnFocus
hideClearAllButton
hidePlaceholderWhenSelected
delay={300}
loadingIndicator={
<p className="py-4 text-center text-muted-foreground text-sm">
<Trans>Loading...</Trans>
</p>
}
emptyIndicator={
<p className="py-4 text-center text-muted-foreground text-sm">
<Trans>No members found</Trans>
</p>
}
/>
);
};
@@ -1,13 +1,3 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { AlertTriangle } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
@@ -25,6 +15,14 @@ import {
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { AlertTriangle } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
export const OrganisationBillingBanner = () => {
const { _ } = useLingui();
@@ -34,8 +32,7 @@ export const OrganisationBillingBanner = () => {
const organisation = useOptionalCurrentOrganisation();
const { mutateAsync: manageSubscription, isPending } =
trpc.enterprise.billing.subscription.manage.useMutation();
const { mutateAsync: manageSubscription, isPending } = trpc.enterprise.billing.subscription.manage.useMutation();
const handleCreatePortal = async (organisationId: string) => {
try {
@@ -58,11 +55,7 @@ export const OrganisationBillingBanner = () => {
const subscriptionStatus = organisation?.subscription?.status;
if (
!organisation ||
subscriptionStatus === undefined ||
subscriptionStatus === SubscriptionStatus.ACTIVE
) {
if (!organisation || subscriptionStatus === undefined || subscriptionStatus === SubscriptionStatus.ACTIVE) {
return null;
}
@@ -70,13 +63,11 @@ export const OrganisationBillingBanner = () => {
<>
<div
className={cn({
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400':
subscriptionStatus === SubscriptionStatus.PAST_DUE,
'bg-destructive text-destructive-foreground':
subscriptionStatus === SubscriptionStatus.INACTIVE,
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400': subscriptionStatus === SubscriptionStatus.PAST_DUE,
'bg-destructive text-destructive-foreground': subscriptionStatus === SubscriptionStatus.INACTIVE,
})}
>
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium">
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 font-medium text-sm">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
@@ -113,22 +104,13 @@ export const OrganisationBillingBanner = () => {
</DialogTitle>
<DialogDescription>
<Trans>
Your payment is overdue. Please settle the payment to avoid any service
disruptions.
</Trans>
<Trans>Your payment is overdue. Please settle the payment to avoid any service disruptions.</Trans>
</DialogDescription>
</DialogHeader>
{canExecuteOrganisationAction(
'MANAGE_BILLING',
organisation.currentOrganisationRole,
) && (
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<DialogFooter>
<Button
loading={isPending}
onClick={async () => handleCreatePortal(organisation.id)}
>
<Button loading={isPending} onClick={async () => handleCreatePortal(organisation.id)}>
<Trans>Resolve payment</Trans>
</Button>
</DialogFooter>
@@ -144,8 +126,7 @@ export const OrganisationBillingBanner = () => {
<DialogDescription>
<Trans>
Your plan is no longer valid. Please subscribe to a new plan to continue using
Documenso.
Your plan is no longer valid. Please subscribe to a new plan to continue using Documenso.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -159,10 +140,7 @@ export const OrganisationBillingBanner = () => {
</AlertDescription>
</Alert>
{canExecuteOrganisationAction(
'MANAGE_BILLING',
organisation.currentOrganisationRole,
) && (
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<DialogFooter>
<DialogClose asChild>
<Button asChild>
@@ -1,22 +1,18 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
export type OrganisationBillingPortalButtonProps = {
buttonProps?: React.ComponentProps<typeof Button>;
};
export const OrganisationBillingPortalButton = ({
buttonProps,
}: OrganisationBillingPortalButtonProps) => {
export const OrganisationBillingPortalButton = ({ buttonProps }: OrganisationBillingPortalButtonProps) => {
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
@@ -24,13 +20,9 @@ export const OrganisationBillingPortalButton = ({
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: manageSubscription, isPending } =
trpc.enterprise.billing.subscription.manage.useMutation();
const { mutateAsync: manageSubscription, isPending } = trpc.enterprise.billing.subscription.manage.useMutation();
const canManageBilling = canExecuteOrganisationAction(
'MANAGE_BILLING',
organisation.currentOrganisationRole,
);
const canManageBilling = canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole);
const handleCreatePortal = async () => {
try {

Some files were not shown because too many files have changed in this diff Show More