diff --git a/src/app/api/v1/documents/[id]/send-invitation/route.ts b/src/app/api/v1/documents/[id]/send-invitation/route.ts index eedabd0b..e2ce201c 100644 --- a/src/app/api/v1/documents/[id]/send-invitation/route.ts +++ b/src/app/api/v1/documents/[id]/send-invitation/route.ts @@ -77,6 +77,10 @@ export const POST = withAuth( documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest', signerRole: (target.signerRole as SignerRole) ?? 'client', senderName: docCfg.developerName ?? null, + // Phase 6 — surface the per-doc rep-authored note when set so + // every cascaded invite and any manual resend show the same + // copy. Falls back to the template default when null/empty. + customMessage: doc.invitationMessage, }); await db diff --git a/src/app/api/v1/interests/[id]/upload-for-signing/route.ts b/src/app/api/v1/interests/[id]/upload-for-signing/route.ts index 6cb59333..c1130033 100644 --- a/src/app/api/v1/interests/[id]/upload-for-signing/route.ts +++ b/src/app/api/v1/interests/[id]/upload-for-signing/route.ts @@ -114,6 +114,11 @@ export const POST = withAuth( // ─── scalar fields ───────────────────────────────────────── const documentType = documentTypeSchema.parse(form.get('documentType')) as CustomDocumentType; const title = z.string().min(1).max(255).parse(form.get('title')); + const invitationMessageRaw = form.get('invitationMessage'); + const invitationMessage = + typeof invitationMessageRaw === 'string' + ? z.string().max(1000).parse(invitationMessageRaw) + : null; // ─── JSON fields ─────────────────────────────────────────── const recipients = parseJsonField( @@ -142,6 +147,7 @@ export const POST = withAuth( signingOrder: r.signingOrder, })), fields, + invitationMessage, meta: { userId: ctx.userId, portId: ctx.portId, diff --git a/src/components/documents/signing-progress.tsx b/src/components/documents/signing-progress.tsx index f28693e3..a72ef2af 100644 --- a/src/components/documents/signing-progress.tsx +++ b/src/components/documents/signing-progress.tsx @@ -1,7 +1,10 @@ 'use client'; import { apiFetch } from '@/lib/api/client'; -import { useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { toastError } from '@/lib/api/toast-error'; interface Signer { id: string; @@ -11,6 +14,10 @@ interface Signer { signingOrder: number; status: string; signedAt?: string | null; + /** Phase 1+2 lifecycle columns surfaced on the API row. */ + invitedAt?: string | null; + openedAt?: string | null; + lastReminderSentAt?: string | null; } interface SigningProgressProps { @@ -36,56 +43,124 @@ const ROLE_LABELS: Record = { approver: 'Sales/Approver', }; +/** + * Phase 6 polish: human-readable "X minutes/hours/days ago" for the + * activity badges (invited / opened / last reminded). Uses + * Intl.RelativeTimeFormat so it follows the user's locale. + */ +function humanRelative(isoOrNull: string | null | undefined): string | null { + if (!isoOrNull) return null; + const t = new Date(isoOrNull).getTime(); + if (Number.isNaN(t)) return null; + const diffMs = Date.now() - t; + const seconds = Math.round(diffMs / 1000); + const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + if (Math.abs(seconds) < 60) return rtf.format(-Math.round(seconds), 'second'); + const minutes = Math.round(seconds / 60); + if (Math.abs(minutes) < 60) return rtf.format(-minutes, 'minute'); + const hours = Math.round(minutes / 60); + if (Math.abs(hours) < 24) return rtf.format(-hours, 'hour'); + const days = Math.round(hours / 24); + return rtf.format(-days, 'day'); +} + export function SigningProgress({ documentId, signers }: SigningProgressProps) { const queryClient = useQueryClient(); const sorted = [...signers].sort((a, b) => a.signingOrder - b.signingOrder); - const handleResend = async (_signer: Signer) => { - try { - await apiFetch(`/api/v1/documents/${documentId}/remind`, { method: 'POST' }); + // Phase 6 — surface reminder cooldown / success / error in a toast + // rather than the silent catch the old handler used. Reps need to + // know whether the manual "Resend" actually fired. + const remindMutation = useMutation({ + mutationFn: (signerId: string) => + apiFetch<{ data: { sent: boolean; reason?: string } }>( + `/api/v1/documents/${documentId}/remind`, + { method: 'POST', body: { signerId } }, + ), + onSuccess: (res) => { + if (res.data.sent) { + toast.success('Reminder sent.'); + } else { + toast.info(res.data.reason ?? 'Reminder skipped.'); + } queryClient.invalidateQueries({ queryKey: ['documents', documentId, 'signers'] }); - } catch { - // silent - } - }; + }, + onError: (err) => toastError(err, 'Failed to send reminder'), + }); return (
- {sorted.map((signer, idx) => ( -
-
-
- {signer.signingOrder} -
-
-

{signer.signerName}

-

- {ROLE_LABELS[signer.signerRole] ?? signer.signerRole} -

-

- {STATUS_LABELS[signer.status] ?? signer.status} -

- {signer.signedAt && ( -

- {new Date(signer.signedAt).toLocaleDateString('en-GB')} + {sorted.map((signer, idx) => { + const invitedAgo = humanRelative(signer.invitedAt); + const openedAgo = humanRelative(signer.openedAt); + const remindedAgo = humanRelative(signer.lastReminderSentAt); + return ( +

+
+
+ {signer.signingOrder} +
+
+

{signer.signerName}

+

+ {ROLE_LABELS[signer.signerRole] ?? signer.signerRole}

- )} - {signer.status === 'pending' && ( - - )} +

+ {STATUS_LABELS[signer.status] ?? signer.status} +

+ {signer.signedAt && ( +

+ {new Date(signer.signedAt).toLocaleDateString('en-GB')} +

+ )} + {/* Phase 6 polish — activity badges so reps can see at a + glance when each signer was last touched. */} + {signer.status === 'pending' && (invitedAgo || openedAgo || remindedAgo) && ( +
+ {invitedAgo && ( +

+ Invited {invitedAgo} +

+ )} + {openedAgo && ( +

+ Opened {openedAgo} +

+ )} + {remindedAgo && ( +

+ Reminded {remindedAgo} +

+ )} +
+ )} + {signer.status === 'pending' && ( + + )} +
+ {idx < sorted.length - 1 &&
}
- {idx < sorted.length - 1 &&
} -
- ))} + ); + })}
); } diff --git a/src/components/documents/upload-for-signing-dialog.tsx b/src/components/documents/upload-for-signing-dialog.tsx index c3585302..428e27a5 100644 --- a/src/components/documents/upload-for-signing-dialog.tsx +++ b/src/components/documents/upload-for-signing-dialog.tsx @@ -195,6 +195,10 @@ function DialogBody({ const [recipients, setRecipients] = useState([]); const [fields, setFields] = useState([]); const [selectedFieldId, setSelectedFieldId] = useState(null); + // Phase 6 polish — optional rep-authored note that appears above the + // CTA in every invitation email for this doc. Empty string means + // "no custom note — use the template default copy". + const [invitationMessage, setInvitationMessage] = useState(''); const docLabel = documentType === 'contract' ? 'Sales Contract' : 'Reservation Agreement'; @@ -314,6 +318,9 @@ function DialogBody({ form.append('documentType', documentType); form.append('title', title || file.name.replace(/\.pdf$/i, '')); form.append('recipients', JSON.stringify(recipients)); + if (invitationMessage.trim()) { + form.append('invitationMessage', invitationMessage.trim()); + } // Strip the client-side `id` from each placed field — the server // assigns its own ids on the documenso side. form.append( @@ -403,6 +410,8 @@ function DialogBody({ onChange={setRecipients} title={title} onTitleChange={setTitle} + invitationMessage={invitationMessage} + onInvitationMessageChange={setInvitationMessage} /> )} {step === 'place-fields' && fileObjectUrl && ( @@ -584,11 +593,15 @@ function RecipientsStep({ onChange, title, onTitleChange, + invitationMessage, + onInvitationMessageChange, }: { recipients: Recipient[]; onChange: (next: Recipient[]) => void; title: string; onTitleChange: (t: string) => void; + invitationMessage: string; + onInvitationMessageChange: (next: string) => void; }) { function update(i: number, patch: Partial) { const next = [...recipients]; @@ -668,6 +681,24 @@ function RecipientsStep({ Add recipient
+
+ +