diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx
index e03744164..7351fad4b 100644
--- a/apps/remix/app/components/general/document/document-edit-form.tsx
+++ b/apps/remix/app/components/general/document/document-edit-form.tsx
@@ -198,6 +198,8 @@ export const DocumentEditForm = ({
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
+ expiryAmount: data.meta.expiryAmount,
+ expiryUnit: data.meta.expiryUnit,
},
});
diff --git a/apps/remix/app/components/general/document/document-page-view-recipients.tsx b/apps/remix/app/components/general/document/document-page-view-recipients.tsx
index 2e413a8ad..88561f5f4 100644
--- a/apps/remix/app/components/general/document/document-page-view-recipients.tsx
+++ b/apps/remix/app/components/general/document/document-page-view-recipients.tsx
@@ -158,6 +158,14 @@ export const DocumentPageViewRecipients = ({
)}
+ {document.status !== DocumentStatus.DRAFT &&
+ recipient.signingStatus === SigningStatus.EXPIRED && (
+
+
+ Expired
+
+ )}
+
{document.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && (
diff --git a/apps/remix/app/components/general/stack-avatar.tsx b/apps/remix/app/components/general/stack-avatar.tsx
index beafbebd5..f46968f7b 100644
--- a/apps/remix/app/components/general/stack-avatar.tsx
+++ b/apps/remix/app/components/general/stack-avatar.tsx
@@ -41,6 +41,9 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
case RecipientStatusType.REJECTED:
classes = 'bg-red-200 text-red-800';
break;
+ case RecipientStatusType.EXPIRED:
+ classes = 'bg-orange-200 text-orange-800';
+ break;
default:
break;
}
diff --git a/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx b/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
index dd1659c3f..2457b1e22 100644
--- a/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
+++ b/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
@@ -48,13 +48,20 @@ export const StackAvatarsWithTooltip = ({
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
);
+ const expiredRecipients = recipients.filter(
+ (recipient) => getRecipientType(recipient) === RecipientStatusType.EXPIRED,
+ );
+
const sortedRecipients = useMemo(() => {
const otherRecipients = recipients.filter(
- (recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
+ (recipient) =>
+ getRecipientType(recipient) !== RecipientStatusType.REJECTED &&
+ getRecipientType(recipient) !== RecipientStatusType.EXPIRED,
);
return [
...rejectedRecipients.sort((a, b) => a.id - b.id),
+ ...expiredRecipients.sort((a, b) => a.id - b.id),
...otherRecipients.sort((a, b) => {
return a.id - b.id;
}),
@@ -117,6 +124,30 @@ export const StackAvatarsWithTooltip = ({
)}
+ {expiredRecipients.length > 0 && (
+
+
+ Expired
+
+ {expiredRecipients.map((recipient: Recipient) => (
+
+
+
+
{recipient.email}
+
+ {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
+
+
+
+ ))}
+
+ )}
+
{waitingRecipients.length > 0 && (
diff --git a/apps/remix/app/components/tables/documents-table-action-button.tsx b/apps/remix/app/components/tables/documents-table-action-button.tsx
index 1333ca912..ef1323479 100644
--- a/apps/remix/app/components/tables/documents-table-action-button.tsx
+++ b/apps/remix/app/components/tables/documents-table-action-button.tsx
@@ -2,7 +2,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
-import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
+import { CheckCircle, Clock, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
@@ -36,6 +36,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
+ const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
const role = recipient?.role;
const isCurrentTeamDocument = team && row.team?.url === team.url;
@@ -87,8 +88,15 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
isPending,
isComplete,
isSigned,
+ isExpired,
isCurrentTeamDocument,
})
+ .with({ isRecipient: true, isExpired: true }, () => (
+
+ ))
.with(
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
() => (
diff --git a/apps/remix/app/components/tables/inbox-table.tsx b/apps/remix/app/components/tables/inbox-table.tsx
index 45f837c17..d63067e70 100644
--- a/apps/remix/app/components/tables/inbox-table.tsx
+++ b/apps/remix/app/components/tables/inbox-table.tsx
@@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus as DocumentStatusEnum } from '@prisma/client';
import { RecipientRole, SigningStatus } from '@prisma/client';
-import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
+import { CheckCircleIcon, Clock, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
@@ -194,6 +194,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
const isPending = row.status === DocumentStatusEnum.PENDING;
const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
+ const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
const role = recipient?.role;
if (!recipient) {
@@ -231,7 +232,14 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
isPending,
isComplete,
isSigned,
+ isExpired,
})
+ .with({ isExpired: true }, () => (
+
+ ))
.with({ isPending: true, isSigned: false }, () => (
diff --git a/packages/ui/primitives/document-flow/add-settings.types.ts b/packages/ui/primitives/document-flow/add-settings.types.ts
index ea70556f0..a1fbdfd6a 100644
--- a/packages/ui/primitives/document-flow/add-settings.types.ts
+++ b/packages/ui/primitives/document-flow/add-settings.types.ts
@@ -46,6 +46,8 @@ export const ZAddSettingsFormSchema = z.object({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
+ expiryAmount: z.number().int().min(1).optional(),
+ expiryUnit: z.enum(['minutes', 'hours', 'days', 'weeks', 'months']).optional(),
}),
});
diff --git a/packages/ui/primitives/duration-selector.tsx b/packages/ui/primitives/duration-selector.tsx
new file mode 100644
index 000000000..e1e5ba0d5
--- /dev/null
+++ b/packages/ui/primitives/duration-selector.tsx
@@ -0,0 +1,106 @@
+'use client';
+
+import React from 'react';
+
+import { useLingui } from '@lingui/react';
+import { DateTime } from 'luxon';
+
+import { cn } from '../lib/utils';
+import { Input } from './input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
+
+export type TimeUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months';
+
+export interface DurationValue {
+ amount: number;
+ unit: TimeUnit;
+}
+
+export interface DurationSelectorProps {
+ value?: DurationValue;
+ onChange?: (value: DurationValue) => void;
+ disabled?: boolean;
+ className?: string;
+ minAmount?: number;
+ maxAmount?: number;
+}
+
+const TIME_UNITS: Array<{ value: TimeUnit; label: string; labelPlural: string }> = [
+ { value: 'minutes', label: 'Minute', labelPlural: 'Minutes' },
+ { value: 'hours', label: 'Hour', labelPlural: 'Hours' },
+ { value: 'days', label: 'Day', labelPlural: 'Days' },
+ { value: 'weeks', label: 'Week', labelPlural: 'Weeks' },
+ { value: 'months', label: 'Month', labelPlural: 'Months' },
+];
+
+export const DurationSelector = ({
+ value = { amount: 1, unit: 'days' },
+ onChange,
+ disabled = false,
+ className,
+ minAmount = 1,
+ maxAmount = 365,
+}: DurationSelectorProps) => {
+ const { _ } = useLingui();
+
+ const handleAmountChange = (event: React.ChangeEvent) => {
+ const amount = parseInt(event.target.value, 10);
+ if (!isNaN(amount) && amount >= minAmount && amount <= maxAmount) {
+ onChange?.({ ...value, amount });
+ }
+ };
+
+ const handleUnitChange = (unit: TimeUnit) => {
+ onChange?.({ ...value, unit });
+ };
+
+ const getUnitLabel = (unit: TimeUnit, amount: number) => {
+ const unitConfig = TIME_UNITS.find((u) => u.value === unit);
+ if (!unitConfig) return unit;
+
+ return amount === 1 ? unitConfig.label : unitConfig.labelPlural;
+ };
+
+ return (
+
+
+
+
+ );
+};
+
+export const calculateExpiryDate = (duration: DurationValue, fromDate: Date = new Date()): Date => {
+ switch (duration.unit) {
+ case 'minutes':
+ return DateTime.fromJSDate(fromDate).plus({ minutes: duration.amount }).toJSDate();
+ case 'hours':
+ return DateTime.fromJSDate(fromDate).plus({ hours: duration.amount }).toJSDate();
+ case 'days':
+ return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate();
+ case 'weeks':
+ return DateTime.fromJSDate(fromDate).plus({ weeks: duration.amount }).toJSDate();
+ case 'months':
+ return DateTime.fromJSDate(fromDate).plus({ months: duration.amount }).toJSDate();
+ default:
+ return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate();
+ }
+};
diff --git a/packages/ui/primitives/expiry-settings-picker.tsx b/packages/ui/primitives/expiry-settings-picker.tsx
new file mode 100644
index 000000000..28d038b4d
--- /dev/null
+++ b/packages/ui/primitives/expiry-settings-picker.tsx
@@ -0,0 +1,121 @@
+'use client';
+
+import React from 'react';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useLingui } from '@lingui/react';
+import { Trans } from '@lingui/react/macro';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { cn } from '../lib/utils';
+import { DurationSelector } from './duration-selector';
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from './form/form';
+
+const ZExpirySettingsSchema = z.object({
+ expiryDuration: z
+ .object({
+ amount: z.number().int().min(1),
+ unit: z.enum(['minutes', 'hours', 'days', 'weeks', 'months']),
+ })
+ .optional(),
+});
+
+export type ExpirySettings = z.infer;
+
+export interface ExpirySettingsPickerProps {
+ className?: string;
+ defaultValues?: Partial;
+ disabled?: boolean;
+ onValueChange?: (value: ExpirySettings) => void;
+ value?: ExpirySettings;
+}
+
+export const ExpirySettingsPicker = ({
+ className,
+ defaultValues = {
+ expiryDuration: undefined,
+ },
+ disabled = false,
+ onValueChange,
+ value,
+}: ExpirySettingsPickerProps) => {
+ const { _ } = useLingui();
+
+ const form = useForm({
+ resolver: zodResolver(ZExpirySettingsSchema),
+ defaultValues,
+ mode: 'onChange',
+ });
+
+ const { watch, setValue, getValues } = form;
+ const expiryDuration = watch('expiryDuration');
+
+ // Call onValueChange when form values change
+ React.useEffect(() => {
+ const subscription = watch((value) => {
+ if (onValueChange) {
+ onValueChange(value as ExpirySettings);
+ }
+ });
+ return () => subscription.unsubscribe();
+ }, [watch, onValueChange]);
+
+ // Keep internal form state in sync when a controlled value is provided
+ React.useEffect(() => {
+ if (value === undefined) return;
+
+ const current = getValues('expiryDuration');
+ const next = value.expiryDuration;
+
+ const amountsDiffer = (current?.amount ?? null) !== (next?.amount ?? null);
+ const unitsDiffer = (current?.unit ?? null) !== (next?.unit ?? null);
+
+ if (amountsDiffer || unitsDiffer) {
+ setValue('expiryDuration', next, {
+ shouldDirty: false,
+ shouldTouch: false,
+ shouldValidate: false,
+ });
+ }
+ }, [value, getValues, setValue]);
+
+ return (
+
+
+
+ );
+};