diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx
index ee23842f3..6b1a695ff 100644
--- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx
+++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx
@@ -218,7 +218,7 @@ export const DocumentSigningPageViewV2 = () => {
)}
{/* Mobile widget - Additional padding to allow users to scroll */}
-
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx
index 43d40d41c..1232573ac 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx
@@ -201,7 +201,7 @@ export const EnvelopeEditorPreviewPage = () => {
envelope={envelope}
token={undefined}
fields={fieldsWithPlaceholders}
- recipientIds={envelope.recipients.map((recipient) => recipient.id)}
+ recipients={envelope.recipients}
overrideSettings={{
mode: 'export',
}}
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx
index 37f7cd5b6..48f13127e 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx
@@ -1,6 +1,7 @@
import { useEffect, useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
+import { type Recipient, SigningStatus } from '@prisma/client';
import type Konva from 'konva';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
@@ -8,12 +9,23 @@ import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/e
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';
+
+type GenericLocalField = TEnvelope['fields'][number] & {
+ recipient: Pick;
+};
export default function EnvelopeGenericPageRenderer() {
const { i18n } = useLingui();
- const { currentEnvelopeItem, fields, getRecipientColorKey, setRenderError, overrideSettings } =
- useCurrentEnvelopeRender();
+ const {
+ currentEnvelopeItem,
+ fields,
+ recipients,
+ getRecipientColorKey,
+ setRenderError,
+ overrideSettings,
+ } = useCurrentEnvelopeRender();
const {
stage,
@@ -29,21 +41,38 @@ export default function EnvelopeGenericPageRenderer() {
const { _className, scale } = pageContext;
- const localPageFields = useMemo(
- () =>
- fields.filter(
+ const localPageFields = useMemo((): GenericLocalField[] => {
+ return fields
+ .filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
- ),
- [fields, pageContext.pageNumber],
- );
+ )
+ .map((field) => {
+ const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
- const unsafeRenderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
+ if (!recipient) {
+ throw new Error(`Recipient not found for field ${field.id}`);
+ }
+
+ return {
+ ...field,
+ recipient,
+ };
+ });
+ }, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
+
+ const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
}
+ const { recipient } = field;
+
+ const fieldTranslations = getClientSideFieldTranslations(i18n);
+
+ const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted;
+
renderField({
scale,
pageLayer: pageLayer.current,
@@ -54,10 +83,14 @@ export default function EnvelopeGenericPageRenderer() {
height: Number(field.height),
positionX: Number(field.positionX),
positionY: Number(field.positionY),
- customText: field.inserted ? field.customText : '',
+ customText: isInserted ? field.customText : '',
fieldMeta: field.fieldMeta,
+ signature: {
+ signatureImageAsBase64: '',
+ typedSignature: fieldTranslations.SIGNATURE,
+ },
},
- translations: getClientSideFieldTranslations(i18n),
+ translations: fieldTranslations,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: getRecipientColorKey(field.recipientId),
@@ -66,7 +99,7 @@ export default function EnvelopeGenericPageRenderer() {
});
};
- const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
+ const renderFieldOnLayer = (field: GenericLocalField) => {
try {
unsafeRenderFieldOnLayer(field);
} catch (err) {
@@ -122,6 +155,16 @@ export default function EnvelopeGenericPageRenderer() {
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
+ {overrideSettings?.showRecipientTooltip &&
+ localPageFields.map((field) => (
+
+ ))}
+
{/* The element Konva will inject it's canvas into. */}
diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx
index 29a768112..e0fb3e665 100644
--- a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx
+++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx
@@ -413,7 +413,6 @@ export default function EnvelopeSignerPageRenderer() {
}
localPageFields.forEach((field) => {
- console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field);
});
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
index 147655a65..4228ec209 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
@@ -148,8 +148,12 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
recipient.id)}
+ fields={envelope.fields}
+ recipients={envelope.recipients}
+ overrideSettings={{
+ showRecipientSigningStatus: true,
+ showRecipientTooltip: true,
+ }}
>
{isMultiEnvelopeItem && (
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx
index e86a51ca7..056b0e08a 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx
@@ -103,7 +103,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
envelope={envelope}
token={undefined}
fields={envelope.fields}
- recipientIds={envelope.recipients.map((recipient) => recipient.id)}
+ recipients={envelope.recipients}
>
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
index 87e8a4aca..7a497a389 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
@@ -172,7 +172,10 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
envelope={envelope}
token={undefined}
fields={envelope.fields}
- recipientIds={envelope.recipients.map((recipient) => recipient.id)}
+ recipients={envelope.recipients}
+ overrideSettings={{
+ showRecipientTooltip: true,
+ }}
>
{isMultiEnvelopeItem && (
diff --git a/packages/lib/client-only/providers/envelope-render-provider.tsx b/packages/lib/client-only/providers/envelope-render-provider.tsx
index fdb8aeb72..3682f311a 100644
--- a/packages/lib/client-only/providers/envelope-render-provider.tsx
+++ b/packages/lib/client-only/providers/envelope-render-provider.tsx
@@ -1,10 +1,13 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import React from 'react';
+import type { Field, Recipient } from '@prisma/client';
+
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import type { TEnvelope } from '../../types/envelope';
+import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
import { getEnvelopeDownloadUrl } from '../../utils/envelope-download';
type FileData =
@@ -17,7 +20,9 @@ type FileData =
};
type EnvelopeRenderOverrideSettings = {
- mode: 'edit' | 'sign' | 'export';
+ mode?: FieldRenderMode;
+ showRecipientTooltip?: boolean;
+ showRecipientSigningStatus?: boolean;
};
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
@@ -27,7 +32,8 @@ type EnvelopeRenderProviderValue = {
envelopeItems: EnvelopeRenderItem[];
currentEnvelopeItem: EnvelopeRenderItem | null;
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
- fields: TEnvelope['fields'];
+ fields: Field[];
+ recipients: Pick[];
getRecipientColorKey: (recipientId: number) => TRecipientColor;
renderError: boolean;
@@ -45,14 +51,15 @@ interface EnvelopeRenderProviderProps {
*
* Only pass if the CustomRenderer you are passing in wants fields.
*/
- fields?: TEnvelope['fields'];
+ fields?: Field[];
/**
- * Optional recipient IDs used to determine the color of the fields.
+ * Optional recipient used to determine the color of the fields and hover
+ * previews.
*
* Only required for generic page renderers.
*/
- recipientIds?: number[];
+ recipients?: Pick[];
/**
* The token to access the envelope.
@@ -87,7 +94,7 @@ export const EnvelopeRenderProvider = ({
envelope,
fields,
token,
- recipientIds = [],
+ recipients = [],
overrideSettings,
}: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId.
@@ -175,6 +182,11 @@ export const EnvelopeRenderProvider = ({
}
}, [envelope.envelopeItems]);
+ const recipientIds = useMemo(
+ () => recipients.map((recipient) => recipient.id).sort(),
+ [recipients],
+ );
+
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
@@ -194,6 +206,7 @@ export const EnvelopeRenderProvider = ({
currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem,
fields: fields ?? [],
+ recipients,
getRecipientColorKey,
renderError,
setRenderError,
diff --git a/packages/lib/universal/field-renderer/field-renderer.ts b/packages/lib/universal/field-renderer/field-renderer.ts
index e925cb0f9..8f66084e2 100644
--- a/packages/lib/universal/field-renderer/field-renderer.ts
+++ b/packages/lib/universal/field-renderer/field-renderer.ts
@@ -19,7 +19,7 @@ export type FieldToRender = Pick<
positionX: number;
positionY: number;
fieldMeta?: TFieldMetaSchema | null;
- signature?: Signature | null;
+ signature?: Pick | null;
};
export type RenderFieldElementOptions = {
diff --git a/packages/lib/universal/field-renderer/render-field.ts b/packages/lib/universal/field-renderer/render-field.ts
index a96d0dd68..2d657f06c 100644
--- a/packages/lib/universal/field-renderer/render-field.ts
+++ b/packages/lib/universal/field-renderer/render-field.ts
@@ -15,6 +15,17 @@ import { renderSignatureFieldElement } from './render-signature-field';
export const MIN_FIELD_HEIGHT_PX = 12;
export const MIN_FIELD_WIDTH_PX = 36;
+/**
+ * The render type.
+ *
+ * @default 'edit'
+ *
+ * - `edit` - The field is rendered in editor page.
+ * - `sign` - The field is rendered for the signing page.
+ * - `export` - The field is rendered for exporting and sealing into the PDF. No backgrounds, interactive elements, etc.
+ */
+export type FieldRenderMode = 'edit' | 'sign' | 'export';
+
export type FieldToRender = Pick<
Field,
'envelopeItemId' | 'recipientId' | 'type' | 'page' | 'customText' | 'inserted' | 'recipientId'
@@ -25,7 +36,7 @@ export type FieldToRender = Pick<
positionX: number;
positionY: number;
fieldMeta?: TFieldMetaSchema | null;
- signature?: Signature | null;
+ signature?: Pick | null;
};
type RenderFieldOptions = {
@@ -38,16 +49,7 @@ type RenderFieldOptions = {
translations: Record | null;
- /**
- * The render type.
- *
- * @default 'edit'
- *
- * - `edit` - The field is rendered in editor page.
- * - `sign` - The field is rendered for the signing page.
- * - `export` - The field is rendered for exporting and sealing into the PDF. No backgrounds, interactive elements, etc.
- */
- mode: 'edit' | 'sign' | 'export';
+ mode: FieldRenderMode;
scale: number;
editable?: boolean;
diff --git a/packages/ui/components/document/envelope-recipient-field-tooltip.tsx b/packages/ui/components/document/envelope-recipient-field-tooltip.tsx
new file mode 100644
index 000000000..323f83e03
--- /dev/null
+++ b/packages/ui/components/document/envelope-recipient-field-tooltip.tsx
@@ -0,0 +1,189 @@
+import { useCallback, useEffect, useState } from 'react';
+
+import { Trans, useLingui } from '@lingui/react/macro';
+import { SigningStatus } from '@prisma/client';
+import type { Field, Recipient } from '@prisma/client';
+import { ClockIcon, EyeOffIcon } from 'lucide-react';
+
+import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
+import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
+
+import { isTemplateRecipientEmailPlaceholder } from '../../../lib/constants/template';
+import { extractInitials } from '../../../lib/utils/recipient-formatter';
+import { SignatureIcon } from '../../icons/signature';
+import { cn } from '../../lib/utils';
+import { Avatar, AvatarFallback } from '../../primitives/avatar';
+import { Badge } from '../../primitives/badge';
+import { FRIENDLY_FIELD_TYPE } from '../../primitives/document-flow/types';
+import { PopoverHover } from '../../primitives/popover';
+
+interface EnvelopeRecipientFieldTooltipProps {
+ field: Pick<
+ Field,
+ 'id' | 'inserted' | 'positionX' | 'positionY' | 'width' | 'height' | 'page' | 'type'
+ > & {
+ recipient: Pick;
+ };
+ showFieldStatus?: boolean;
+ showRecipientTooltip?: boolean;
+ showRecipientColors?: boolean;
+}
+
+const getRecipientDisplayText = (recipient: { name: string; email: string }) => {
+ if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) {
+ return `${recipient.name} (${recipient.email})`;
+ }
+
+ if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) {
+ return recipient.name;
+ }
+
+ return recipient.email;
+};
+
+/**
+ * Renders a tooltip for a given field.
+ */
+export function EnvelopeRecipientFieldTooltip({
+ field,
+ showFieldStatus = false,
+ showRecipientTooltip = false,
+ showRecipientColors = false,
+}: EnvelopeRecipientFieldTooltipProps) {
+ const { t } = useLingui();
+
+ const [hideField, setHideField] = useState(!showRecipientTooltip);
+
+ const [coords, setCoords] = useState({
+ x: 0,
+ y: 0,
+ height: 0,
+ width: 0,
+ });
+
+ const calculateCoords = useCallback(() => {
+ const $page = document.querySelector(
+ `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
+ );
+
+ if (!$page) {
+ return;
+ }
+
+ const { height, width } = getBoundingClientRect($page);
+
+ const fieldHeight = (Number(field.height) / 100) * height;
+ const fieldWidth = (Number(field.width) / 100) * width;
+
+ const fieldX = (Number(field.positionX) / 100) * width + Number(fieldWidth);
+ const fieldY = (Number(field.positionY) / 100) * height;
+
+ setCoords({
+ x: fieldX,
+ y: fieldY,
+ height: fieldHeight,
+ width: fieldWidth,
+ });
+ }, [field.height, field.page, field.positionX, field.positionY, field.width]);
+
+ useEffect(() => {
+ calculateCoords();
+ }, [calculateCoords]);
+
+ useEffect(() => {
+ const onResize = () => {
+ calculateCoords();
+ };
+
+ window.addEventListener('resize', onResize);
+
+ return () => {
+ window.removeEventListener('resize', onResize);
+ };
+ }, [calculateCoords]);
+
+ useEffect(() => {
+ const $page = document.querySelector(
+ `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
+ );
+
+ if (!$page) {
+ return;
+ }
+
+ const observer = new ResizeObserver(() => {
+ calculateCoords();
+ });
+
+ observer.observe($page);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [calculateCoords, field.page]);
+
+ if (hideField) {
+ return null;
+ }
+
+ return (
+
+ );
+}