mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 17:35:05 +10:00
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:
@@ -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({
|
||||
|
||||
+12
-30
@@ -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])}
|
||||
|
||||
+6
-8
@@ -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>
|
||||
|
||||
|
||||
+27
-44
@@ -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>
|
||||
)}
|
||||
|
||||
+7
-10
@@ -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>
|
||||
)}
|
||||
|
||||
+98
-33
@@ -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>
|
||||
|
||||
|
||||
+17
-47
@@ -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.
|
||||
|
||||
+86
-42
@@ -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>
|
||||
|
||||
+7
-22
@@ -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>
|
||||
)}
|
||||
|
||||
+10
-25
@@ -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>
|
||||
|
||||
|
||||
+23
-50
@@ -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>
|
||||
|
||||
+107
-128
@@ -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 && (
|
||||
|
||||
+12
-18
@@ -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>
|
||||
.
|
||||
|
||||
+15
-33
@@ -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>
|
||||
|
||||
+7
-14
@@ -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;
|
||||
|
||||
|
||||
+56
-80
@@ -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>
|
||||
|
||||
+7
-15
@@ -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 && (
|
||||
|
||||
+28
-37
@@ -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>
|
||||
)}
|
||||
|
||||
+11
-31
@@ -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>
|
||||
|
||||
+17
-33
@@ -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>
|
||||
))}
|
||||
|
||||
+58
-81
@@ -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,
|
||||
|
||||
+167
-129
@@ -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;
|
||||
|
||||
+10
-27
@@ -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>
|
||||
)}
|
||||
|
||||
+5
-14
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+10
-22
@@ -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>
|
||||
|
||||
|
||||
+13
-26
@@ -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>
|
||||
|
||||
+11
-29
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
+7
-11
@@ -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);
|
||||
}}
|
||||
|
||||
+11
-18
@@ -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>
|
||||
);
|
||||
};
|
||||
+27
-65
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
+83
-126
@@ -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}
|
||||
|
||||
+133
-84
@@ -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: </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: </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: </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: </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>
|
||||
|
||||
+52
-43
@@ -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"
|
||||
|
||||
+413
-447
File diff suppressed because it is too large
Load Diff
+28
@@ -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>
|
||||
);
|
||||
};
|
||||
+441
-351
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,
|
||||
|
||||
+346
-104
@@ -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>
|
||||
|
||||
+38
-50
@@ -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 />
|
||||
|
||||
+36
-65
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+19
-17
@@ -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>
|
||||
|
||||
+7
-15
@@ -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
Reference in New Issue
Block a user