Added typed signature field
X-Lovable-Edit-ID: edt-88b7aa60-ccde-4d43-8965-79fbd145a248 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -2443,6 +2443,7 @@ export type Database = {
|
||||
phone: string | null
|
||||
signature_block: string | null
|
||||
signature_image_path: string | null
|
||||
signature_typed: string | null
|
||||
timezone: string | null
|
||||
title: string | null
|
||||
updated_at: string
|
||||
@@ -2458,6 +2459,7 @@ export type Database = {
|
||||
phone?: string | null
|
||||
signature_block?: string | null
|
||||
signature_image_path?: string | null
|
||||
signature_typed?: string | null
|
||||
timezone?: string | null
|
||||
title?: string | null
|
||||
updated_at?: string
|
||||
@@ -2473,6 +2475,7 @@ export type Database = {
|
||||
phone?: string | null
|
||||
signature_block?: string | null
|
||||
signature_image_path?: string | null
|
||||
signature_typed?: string | null
|
||||
timezone?: string | null
|
||||
title?: string | null
|
||||
updated_at?: string
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface PleadingFootnote {
|
||||
export interface PleadingSignature {
|
||||
/** Multi-line block placed under the signature line (name, bar no., firm, etc.) */
|
||||
block: string;
|
||||
/** Optional typed signature (rendered in a cursive script font above the line) */
|
||||
typed?: string;
|
||||
/** Optional PNG/JPG bytes of a scanned signature placed above the line (for DOCX) */
|
||||
imageBytes?: ArrayBuffer | Uint8Array;
|
||||
/** Same image as a data URL (for PDF generator, which needs a string) */
|
||||
@@ -304,9 +306,12 @@ export function buildPleadingDoc(input: PleadingInput): Document {
|
||||
input.body.split("\n").forEach((line) => bodyParagraphs.push(p(line)));
|
||||
}
|
||||
|
||||
// Signature block (above footnotes, after body)
|
||||
// Signature block (above footnotes, after body) — right-side, indented ~2/3 of page width
|
||||
const sig = input.signature;
|
||||
if (sig && (sig.block?.trim() || sig.imageBytes)) {
|
||||
const SIG_INDENT = 6240; // ~4.33" from left margin (page content is 9360 dxa wide)
|
||||
const sigParagraph = (children: TextRun[]) =>
|
||||
new Paragraph({ indent: { left: SIG_INDENT }, children });
|
||||
if (sig && (sig.block?.trim() || sig.imageBytes || sig.typed?.trim())) {
|
||||
bodyParagraphs.push(emptyP());
|
||||
bodyParagraphs.push(emptyP());
|
||||
if (sig.imageBytes) {
|
||||
@@ -316,24 +321,39 @@ export function buildPleadingDoc(input: PleadingInput): Document {
|
||||
: new Uint8Array(sig.imageBytes);
|
||||
bodyParagraphs.push(
|
||||
new Paragraph({
|
||||
indent: { left: SIG_INDENT },
|
||||
children: [
|
||||
new ImageRun({
|
||||
type: (sig.imageType as any) || "png",
|
||||
data,
|
||||
transformation: { width: 220, height: 70 },
|
||||
transformation: { width: 200, height: 60 },
|
||||
altText: { title: "Signature", description: "Attorney signature", name: "signature" },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else if (sig.typed?.trim()) {
|
||||
// Typed cursive signature — script font, larger size
|
||||
bodyParagraphs.push(
|
||||
new Paragraph({
|
||||
indent: { left: SIG_INDENT },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: sig.typed.trim(),
|
||||
font: "Lucida Handwriting",
|
||||
size: 36,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// Reserve vertical space for a wet signature
|
||||
bodyParagraphs.push(emptyP());
|
||||
bodyParagraphs.push(emptyP());
|
||||
}
|
||||
// Signature line (underscore rule, ~3 inches)
|
||||
bodyParagraphs.push(p("_______________________________"));
|
||||
sig.block.split("\n").forEach((line) => bodyParagraphs.push(p(line)));
|
||||
bodyParagraphs.push(sigParagraph([run("_______________________________")]));
|
||||
sig.block.split("\n").forEach((line) =>
|
||||
bodyParagraphs.push(sigParagraph([run(line)])),
|
||||
);
|
||||
}
|
||||
|
||||
// Footnotes rendered as endnote-style block at bottom of document
|
||||
|
||||
+13
-8
@@ -272,29 +272,34 @@ export async function downloadPleadingPdf(input: PleadingInput, filename: string
|
||||
});
|
||||
}
|
||||
|
||||
// Signature block (above footnotes)
|
||||
// Signature block (above footnotes) — right-side, indented ~2/3 of page width
|
||||
const sig = input.signature;
|
||||
if (sig && (sig.block?.trim() || sig.imageDataUrl)) {
|
||||
const SIG_X = MARGIN + CONTENT_W * (2 / 3);
|
||||
if (sig && (sig.block?.trim() || sig.imageDataUrl || sig.typed?.trim())) {
|
||||
ensureSpace(LINE_H * 6);
|
||||
y += LINE_H * 1.5;
|
||||
if (sig.imageDataUrl) {
|
||||
try {
|
||||
const fmt = (sig.imageType || "png").toUpperCase();
|
||||
// ~3 inches wide, ~1 inch tall
|
||||
pdf.addImage(sig.imageDataUrl, fmt as any, MARGIN, y - LINE_H, 220, 70);
|
||||
y += 70 - LINE_H + 4;
|
||||
pdf.addImage(sig.imageDataUrl, fmt as any, SIG_X, y - LINE_H, 150, 50);
|
||||
y += 50 - LINE_H + 4;
|
||||
} catch {
|
||||
y += LINE_H * 2;
|
||||
}
|
||||
} else if (sig.typed?.trim()) {
|
||||
// Typed cursive signature — fall back to italic Bookman (script fonts not embedded)
|
||||
setFont(pdf, { italic: true }, 18);
|
||||
pdf.text(sig.typed.trim(), SIG_X, y);
|
||||
y += LINE_H;
|
||||
} else {
|
||||
y += LINE_H * 3; // reserve space for a wet signature
|
||||
y += LINE_H * 3;
|
||||
}
|
||||
setFont(pdf, {});
|
||||
pdf.text("_______________________________", MARGIN, y);
|
||||
pdf.text("_______________________________", SIG_X, y);
|
||||
y += LINE_H;
|
||||
sig.block.split("\n").forEach((line) => {
|
||||
ensureSpace(LINE_H);
|
||||
pdf.text(line, MARGIN, y);
|
||||
pdf.text(line, SIG_X, y);
|
||||
y += LINE_H;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ function PleadingNewPage() {
|
||||
// Attorney signature (loaded from profile)
|
||||
const [includeSignature, setIncludeSignature] = useState(true);
|
||||
const [sigBlock, setSigBlock] = useState("");
|
||||
const [sigTyped, setSigTyped] = useState("");
|
||||
const [sigImagePath, setSigImagePath] = useState<string>("");
|
||||
const [sigImageBytes, setSigImageBytes] = useState<Uint8Array | null>(null);
|
||||
const [sigImageDataUrl, setSigImageDataUrl] = useState<string>("");
|
||||
@@ -66,6 +67,7 @@ function PleadingNewPage() {
|
||||
if (!data) return;
|
||||
const d = data as any;
|
||||
setSigBlock(d.signature_block ?? "");
|
||||
setSigTyped(d.signature_typed ?? "");
|
||||
setSigImagePath(d.signature_image_path ?? "");
|
||||
if (d.signature_image_path) {
|
||||
try {
|
||||
@@ -76,7 +78,6 @@ function PleadingNewPage() {
|
||||
const blob = await res.blob();
|
||||
const buf = new Uint8Array(await blob.arrayBuffer());
|
||||
setSigImageBytes(buf);
|
||||
// Build a data URL for the PDF generator
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setSigImageDataUrl(String(reader.result || ""));
|
||||
reader.readAsDataURL(blob);
|
||||
@@ -91,9 +92,10 @@ function PleadingNewPage() {
|
||||
|
||||
const buildSignature = (): PleadingSignature | undefined => {
|
||||
if (!includeSignature) return undefined;
|
||||
if (!sigBlock.trim() && !sigImageBytes) return undefined;
|
||||
if (!sigBlock.trim() && !sigImageBytes && !sigTyped.trim()) return undefined;
|
||||
return {
|
||||
block: sigBlock,
|
||||
typed: sigTyped || undefined,
|
||||
imageBytes: sigImageBytes ?? undefined,
|
||||
imageDataUrl: sigImageDataUrl || undefined,
|
||||
imageType: sigImageType,
|
||||
@@ -398,7 +400,7 @@ function PleadingNewPage() {
|
||||
</div>
|
||||
|
||||
{includeSignature ? (
|
||||
!sigBlock.trim() && !sigImagePath ? (
|
||||
!sigBlock.trim() && !sigImagePath && !sigTyped.trim() ? (
|
||||
<div className="text-sm italic text-muted-foreground border border-dashed rounded p-3">
|
||||
No signature found on your profile yet. Add one in Settings → Profile.
|
||||
</div>
|
||||
@@ -407,19 +409,28 @@ function PleadingNewPage() {
|
||||
className="border rounded-md p-4 bg-white text-black"
|
||||
style={{ fontFamily: '"Bookman Old Style", Georgia, serif', fontSize: 12 }}
|
||||
>
|
||||
{sigImagePath && sigImageDataUrl ? (
|
||||
<img
|
||||
src={sigImageDataUrl}
|
||||
alt="Signature"
|
||||
className="mb-1 max-h-[60px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10" />
|
||||
)}
|
||||
<div>_______________________________</div>
|
||||
<pre className="whitespace-pre-wrap font-[inherit] text-[12px] leading-snug mt-1">
|
||||
{sigBlock || <span className="text-muted-foreground italic">(no block text)</span>}
|
||||
</pre>
|
||||
<div style={{ marginLeft: "66.6%" }}>
|
||||
{sigImagePath && sigImageDataUrl ? (
|
||||
<img
|
||||
src={sigImageDataUrl}
|
||||
alt="Signature"
|
||||
className="mb-1 max-h-[50px] object-contain"
|
||||
/>
|
||||
) : sigTyped.trim() ? (
|
||||
<div
|
||||
className="mb-1"
|
||||
style={{ fontFamily: '"Lucida Handwriting", "Brush Script MT", cursive', fontSize: 18 }}
|
||||
>
|
||||
{sigTyped}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-10" />
|
||||
)}
|
||||
<div>_______________________________</div>
|
||||
<pre className="whitespace-pre-wrap font-[inherit] text-[12px] leading-snug mt-1">
|
||||
{sigBlock || <span className="text-muted-foreground italic">(no block text)</span>}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -23,6 +23,7 @@ const EMPTY = {
|
||||
timezone: "",
|
||||
avatar_url: "" as string | null | "",
|
||||
signature_block: "",
|
||||
signature_typed: "",
|
||||
signature_image_path: "" as string | null | "",
|
||||
};
|
||||
|
||||
@@ -66,6 +67,7 @@ function ProfilePage() {
|
||||
timezone: d.timezone ?? "",
|
||||
avatar_url: d.avatar_url ?? "",
|
||||
signature_block: d.signature_block ?? "",
|
||||
signature_typed: d.signature_typed ?? "",
|
||||
signature_image_path: d.signature_image_path ?? "",
|
||||
});
|
||||
}
|
||||
@@ -184,6 +186,7 @@ function ProfilePage() {
|
||||
bio: form.bio || null,
|
||||
timezone: form.timezone || null,
|
||||
signature_block: form.signature_block || null,
|
||||
signature_typed: form.signature_typed || null,
|
||||
} as any)
|
||||
.eq("id", user.id);
|
||||
setSaving(false);
|
||||
@@ -323,6 +326,29 @@ function ProfilePage() {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Typed signature (optional)</Label>
|
||||
<Input
|
||||
value={form.signature_typed}
|
||||
onChange={(e) => update("signature_typed", e.target.value)}
|
||||
placeholder="e.g. /s/ Jane Q. Attorney"
|
||||
/>
|
||||
{form.signature_typed && (
|
||||
<div className="border rounded-md bg-white p-3">
|
||||
<span
|
||||
className="text-2xl text-black"
|
||||
style={{ fontFamily: '"Lucida Handwriting", "Brush Script MT", cursive' }}
|
||||
>
|
||||
{form.signature_typed}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used above the signature line if no signature image is uploaded. Rendered in a script font.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Signature image (optional)</Label>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS signature_typed TEXT;
|
||||
Reference in New Issue
Block a user