From 7e3777b8b2e24227bf1d22d6691d32358812a330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CPeteroche=E2=80=9D?= <“petergoddey08l@gmail.com”> Date: Fri, 7 Nov 2025 10:37:49 +0100 Subject: [PATCH 1/2] Feature: PDF Export --- package-lock.json | 226 +++++++++++++++++++++++ package.json | 1 + src/components/escrow/escrow-content.tsx | 18 +- src/utils/pdf/exportEscrowReport.ts | 206 +++++++++++++++++++++ 4 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 src/utils/pdf/exportEscrowReport.ts diff --git a/package-lock.json b/package-lock.json index ebf4c48..ff0d79e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.5.0", + "jspdf": "^3.0.3", "lucide-react": "^0.482.0", "next": "15.2.3", "next-themes": "^0.4.6", @@ -53,6 +54,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.3.1.tgz", @@ -3264,6 +3274,19 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.0.11", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.11.tgz", @@ -3284,6 +3307,13 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.26.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.1.tgz", @@ -4080,6 +4110,16 @@ "node": ">=0.12.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4247,6 +4287,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4355,6 +4415,18 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz", + "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4370,6 +4442,16 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -4537,6 +4619,16 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", + "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5241,6 +5333,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -5260,6 +5363,12 @@ "is-retry-allowed": "^3.0.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5679,6 +5788,20 @@ "node": ">= 0.4" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5757,6 +5880,12 @@ "node": ">= 0.4" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6260,6 +6389,23 @@ "json5": "lib/cli.js" } }, + "node_modules/jspdf": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz", + "integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.9", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.2.4", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -7010,6 +7156,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7050,6 +7202,13 @@ "dev": true, "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7167,6 +7326,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -7296,6 +7465,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -7383,6 +7559,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rspack-resolver": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/rspack-resolver/-/rspack-resolver-1.1.2.tgz", @@ -7770,6 +7956,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -7963,6 +8159,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwind-merge": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz", @@ -7998,6 +8204,16 @@ "node": ">=6" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinyglobby": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz", @@ -8290,6 +8506,16 @@ } } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index c5a9a3b..10823b4 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.5.0", + "jspdf": "^3.0.3", "lucide-react": "^0.482.0", "next": "15.2.3", "next-themes": "^0.4.6", diff --git a/src/components/escrow/escrow-content.tsx b/src/components/escrow/escrow-content.tsx index 1ce543c..0192191 100644 --- a/src/components/escrow/escrow-content.tsx +++ b/src/components/escrow/escrow-content.tsx @@ -8,6 +8,9 @@ import { DesktopView } from "@/components/escrow/desktop-view" import { WelcomeState } from "@/components/escrow/welcome-state" import error from "next/error" import { useNetwork } from "@/contexts/NetworkContext"; // Add this line + import { Button } from "@/components/ui/button" + import { exportEscrowReport } from "@/utils/pdf/exportEscrowReport" +import { FileDown } from "lucide-react" interface EscrowContentProps { @@ -48,7 +51,8 @@ export const EscrowContent = ({ variants={staggerContainer} className="w-full max-w-5xl" > - {/* Title Card */} + {/* Title Card */ + } +
+ +
+ {/* Mobile view: Use tabs for compact display */} {isMobile && } diff --git a/src/utils/pdf/exportEscrowReport.ts b/src/utils/pdf/exportEscrowReport.ts new file mode 100644 index 0000000..b3c439b --- /dev/null +++ b/src/utils/pdf/exportEscrowReport.ts @@ -0,0 +1,206 @@ +import { jsPDF } from "jspdf"; +import type { OrganizedEscrowData } from "@/mappers/escrow-mapper"; +import { ROLE_MAPPING } from "@/lib/escrow-constants"; + +const DEFAULT_FONT = "helvetica"; + +export type NetworkTag = "mainnet" | "testnet"; + +async function loadLogoDataUrl(path: string): Promise { + try { + const img = new Image(); + img.crossOrigin = "Anonymous"; + const dataUrl: string = await new Promise((resolve, reject) => { + img.onload = () => { + try { + const canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext("2d"); + if (!ctx) return reject(new Error("Canvas not supported")); + ctx.drawImage(img, 0, 0); + resolve(canvas.toDataURL("image/png")); + } catch (e) { + reject(e); + } + }; + img.onerror = () => reject(new Error("Failed to load logo")); + img.src = path; + }); + return dataUrl; + } catch { + return null; + } +} + +export async function exportEscrowReport(organized: OrganizedEscrowData, network: NetworkTag) { + const doc = new jsPDF({ unit: "pt", format: "a4" }); + const pageWidth = doc.internal.pageSize.getWidth(); + const margin = 64; // add more outer spacing + let y = margin; + const logoDataUrl = await loadLogoDataUrl("/logo.png"); + + const line = (height = 16) => { + y += height; + if (y > doc.internal.pageSize.getHeight() - margin) { + doc.addPage(); + y = margin; + renderHeader(logoDataUrl || undefined); + } + }; + + const text = ( + content: string, + opts?: { size?: number; bold?: boolean; lineHeight?: number } + ) => { + if (opts?.bold) doc.setFont(DEFAULT_FONT, "bold"); + else doc.setFont(DEFAULT_FONT, "normal"); + if (opts?.size) doc.setFontSize(opts.size); + const maxWidth = pageWidth - margin * 2; + const wrapped = doc.splitTextToSize(content, maxWidth); + const lh = opts?.lineHeight ?? 16; + wrapped.forEach((lineText: string, idx: number) => { + doc.text(lineText, margin, y); + if (idx < wrapped.length - 1) { + line(lh); + } + }); + }; + + const rightText = (content: string, opts?: { size?: number }) => { + if (opts?.size) doc.setFontSize(opts.size); + const textWidth = doc.getTextWidth(content); + doc.text(content, pageWidth - margin - textWidth, y); + }; + + const renderHeader = (logoDataUrl?: string) => { + doc.setFontSize(18); + doc.setFont(DEFAULT_FONT, "bold"); + // Logo (if available) + if (logoDataUrl) { + const logoHeight = 24; + const logoWidth = 24; + doc.addImage(logoDataUrl, "PNG", margin, y - 16, logoWidth, logoHeight); + doc.text("Trustless Work — Escrow Audit Report", margin + logoWidth + 8, y); + } else { + doc.text("Trustless Work — Escrow Audit Report", margin, y); + } + rightText(network === "mainnet" ? "Mainnet" : "Testnet", { size: 12 }); + line(20); + doc.setDrawColor(41, 98, 255); + doc.setLineWidth(1); + doc.line(margin, y, pageWidth - margin, y); + line(16); + }; + + const renderFooter = () => { + const footerY = doc.internal.pageSize.getHeight() - margin / 2; + doc.setFontSize(10); + doc.setFont(DEFAULT_FONT, "normal"); + doc.setTextColor(120); + doc.text( + "https://viewer.trustlesswork.com • Source: https://github.com/trustlesswork/escrow-viewer", + margin, + footerY + ); + doc.setTextColor(0); + }; + + renderHeader(logoDataUrl || undefined); + + // Report meta + doc.setFontSize(12); + doc.setFont(DEFAULT_FONT, "normal"); + text(`Generated: ${new Date().toISOString()}`); + line(20); + + // Title & description + doc.setFontSize(16); + doc.setFont(DEFAULT_FONT, "bold"); + text(organized.title || "Escrow"); + line(16); + doc.setFontSize(12); + doc.setFont(DEFAULT_FONT, "normal"); + text(organized.description || ""); + line(12); + + // Properties + doc.setFontSize(14); + doc.setFont(DEFAULT_FONT, "bold"); + text("Escrow Details"); + line(14); + doc.setFontSize(12); + doc.setFont(DEFAULT_FONT, "normal"); + + const props = organized.properties; + const details: Array<[string, string]> = [ + ["Escrow ID", props.escrow_id], + ["Engagement ID", String(props.engagement_id || "-")], + ["Amount", String(props.amount || "-")], + ["Balance", String(props.balance || "-")], + ["Platform Fee", String(props.platform_fee || "-")], + ["Asset (trustline)", String(props.trustline || "-")], + ]; + + details.forEach(([k, v]) => { + text(`${k}: ${v}`); + line(14); + }); + + line(6); + + // Roles + doc.setFontSize(14); + doc.setFont(DEFAULT_FONT, "bold"); + text("Assigned Roles"); + line(14); + doc.setFontSize(12); + doc.setFont(DEFAULT_FONT, "normal"); + Object.entries(organized.roles).forEach(([key, value]) => { + const label = ROLE_MAPPING[key] || key.replace(/_/g, " "); + text(`${label}: ${value}`); + line(14); + }); + + line(6); + + // Milestones + doc.setFontSize(14); + doc.setFont(DEFAULT_FONT, "bold"); + text("Milestones"); + line(14); + doc.setFontSize(12); + doc.setFont(DEFAULT_FONT, "normal"); + + if (organized.milestones.length === 0) { + text("No milestones found"); + line(14); + } else { + organized.milestones.forEach((m, idx) => { + text(`${idx + 1}. ${m.title}` , { bold: true }); + line(14); + text(`Status: ${m.status}${m.approved ? " (approved)" : ""}`); + line(14); + if (m.amount) { + text(`Amount: ${m.amount}`); + line(14); + } + if (typeof m.signer === "string") { + text(`Signer: ${m.signer}`); + line(14); + } + if (typeof m.approver === "string") { + text(`Approver: ${m.approver}`); + line(14); + } + line(8); + }); + } + + // Footer on last page + renderFooter(); + + // Save + const filename = `trustlesswork-escrow-${props.escrow_id}-${network}.pdf`; + doc.save(filename); +} From 2fa85e2051826463fbfb0a0e970d4b51c24d6de6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CPeteroche=E2=80=9D?= <“petergoddey08l@gmail.com”> Date: Sat, 8 Nov 2025 13:24:00 +0100 Subject: [PATCH 2/2] fix: coderabbit review --- src/components/escrow/escrow-content.tsx | 34 +- src/utils/pdf/exportEscrowReport.ts | 390 +++++++++++++---------- 2 files changed, 258 insertions(+), 166 deletions(-) diff --git a/src/components/escrow/escrow-content.tsx b/src/components/escrow/escrow-content.tsx index 0192191..30713da 100644 --- a/src/components/escrow/escrow-content.tsx +++ b/src/components/escrow/escrow-content.tsx @@ -1,4 +1,5 @@ import { motion, AnimatePresence } from "framer-motion" +import { useState } from "react" import type { OrganizedEscrowData } from "@/mappers/escrow-mapper"; import { staggerContainer } from "@/utils/animations/animation-variants" import { LoadingLogo } from "@/components/shared/loading-logo" @@ -10,7 +11,8 @@ import error from "next/error" import { useNetwork } from "@/contexts/NetworkContext"; // Add this line import { Button } from "@/components/ui/button" import { exportEscrowReport } from "@/utils/pdf/exportEscrowReport" -import { FileDown } from "lucide-react" +import { FileDown, Loader2 } from "lucide-react" +import { toast } from "sonner" interface EscrowContentProps { @@ -25,6 +27,7 @@ export const EscrowContent = ({ isMobile, }: EscrowContentProps) => { const { currentNetwork } = useNetwork(); // Get network from context + const [exporting, setExporting] = useState(false) return (
@@ -69,10 +72,33 @@ export const EscrowContent = ({ variant="default" size="lg" aria-label="Export escrow as PDF" - onClick={() => exportEscrowReport(organized, currentNetwork)} + disabled={exporting} + onClick={async () => { + if (!organized) return + setExporting(true) + try { + await exportEscrowReport(organized, currentNetwork) + toast.success("PDF exported successfully") + } catch (e) { + const errorMessage = e instanceof Error ? e.message : "Failed to export PDF" + console.error("PDF export error:", e) + toast.error(`Failed to export PDF: ${errorMessage}`) + } finally { + setExporting(false) + } + }} > - - Export to PDF + {exporting ? ( + <> + + Exporting… + + ) : ( + <> + + Export to PDF + + )}
diff --git a/src/utils/pdf/exportEscrowReport.ts b/src/utils/pdf/exportEscrowReport.ts index b3c439b..2a987df 100644 --- a/src/utils/pdf/exportEscrowReport.ts +++ b/src/utils/pdf/exportEscrowReport.ts @@ -7,200 +7,266 @@ const DEFAULT_FONT = "helvetica"; export type NetworkTag = "mainnet" | "testnet"; async function loadLogoDataUrl(path: string): Promise { + let img: HTMLImageElement | null = null; + let timeoutId: ReturnType | null = null; + let settled = false; + try { - const img = new Image(); + img = new Image(); img.crossOrigin = "Anonymous"; - const dataUrl: string = await new Promise((resolve, reject) => { - img.onload = () => { + + // Capture img in a local constant so TypeScript knows it's non-null + const imageElement: HTMLImageElement = img; + + const loadPromise = new Promise((resolve, reject) => { + const cleanup = () => { + imageElement.onload = null; + imageElement.onerror = null; + // Abort image loading to prevent leaks + try { + imageElement.src = ""; + } catch {} + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + + imageElement.onload = () => { + if (settled) return; + settled = true; try { const canvas = document.createElement("canvas"); - canvas.width = img.width; - canvas.height = img.height; + canvas.width = imageElement.width; + canvas.height = imageElement.height; const ctx = canvas.getContext("2d"); - if (!ctx) return reject(new Error("Canvas not supported")); - ctx.drawImage(img, 0, 0); - resolve(canvas.toDataURL("image/png")); + if (!ctx) throw new Error("Canvas not supported"); + ctx.drawImage(imageElement, 0, 0); + const url = canvas.toDataURL("image/png"); + cleanup(); + resolve(url); } catch (e) { + cleanup(); reject(e); } }; - img.onerror = () => reject(new Error("Failed to load logo")); - img.src = path; + + imageElement.onerror = () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("Failed to load logo")); + }; + + // Set timeout to race against image load (5s timeout) + timeoutId = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("Logo load timeout")); + }, 5000); + + // Start loading last to avoid race with handlers + imageElement.src = path; }); + + const dataUrl = await loadPromise; return dataUrl; } catch { + // On timeout or any error, return null so export doesn't hang + if (img) { + try { + img.onload = null; + img.onerror = null; + img.src = ""; + } catch {} + } + if (timeoutId) { + clearTimeout(timeoutId); + } return null; } } export async function exportEscrowReport(organized: OrganizedEscrowData, network: NetworkTag) { - const doc = new jsPDF({ unit: "pt", format: "a4" }); - const pageWidth = doc.internal.pageSize.getWidth(); - const margin = 64; // add more outer spacing - let y = margin; - const logoDataUrl = await loadLogoDataUrl("/logo.png"); - - const line = (height = 16) => { - y += height; - if (y > doc.internal.pageSize.getHeight() - margin) { - doc.addPage(); - y = margin; - renderHeader(logoDataUrl || undefined); - } - }; - - const text = ( - content: string, - opts?: { size?: number; bold?: boolean; lineHeight?: number } - ) => { - if (opts?.bold) doc.setFont(DEFAULT_FONT, "bold"); - else doc.setFont(DEFAULT_FONT, "normal"); - if (opts?.size) doc.setFontSize(opts.size); - const maxWidth = pageWidth - margin * 2; - const wrapped = doc.splitTextToSize(content, maxWidth); - const lh = opts?.lineHeight ?? 16; - wrapped.forEach((lineText: string, idx: number) => { - doc.text(lineText, margin, y); - if (idx < wrapped.length - 1) { - line(lh); + try { + const doc = new jsPDF({ unit: "pt", format: "a4" }); + const pageWidth = doc.internal.pageSize.getWidth(); + const margin = 64; // add more outer spacing + let y = margin; + const logoDataUrl = await loadLogoDataUrl("/logo.png"); + + const line = (height = 16) => { + y += height; + if (y > doc.internal.pageSize.getHeight() - margin) { + // add footer to current page before breaking + renderFooter(); + doc.addPage(); + y = margin; + renderHeader(logoDataUrl || undefined); } - }); - }; + }; + + const text = ( + content: string, + opts?: { size?: number; bold?: boolean; lineHeight?: number } + ) => { + if (opts?.bold) doc.setFont(DEFAULT_FONT, "bold"); + else doc.setFont(DEFAULT_FONT, "normal"); + if (opts?.size) doc.setFontSize(opts.size); + const maxWidth = pageWidth - margin * 2; + const wrapped = doc.splitTextToSize(content, maxWidth); + const lh = opts?.lineHeight ?? 16; + wrapped.forEach((lineText: string, idx: number) => { + doc.text(lineText, margin, y); + if (idx < wrapped.length - 1) { + line(lh); + } + }); + }; - const rightText = (content: string, opts?: { size?: number }) => { - if (opts?.size) doc.setFontSize(opts.size); - const textWidth = doc.getTextWidth(content); - doc.text(content, pageWidth - margin - textWidth, y); - }; + const rightText = (content: string, opts?: { size?: number }) => { + if (opts?.size) doc.setFontSize(opts.size); + const textWidth = doc.getTextWidth(content); + doc.text(content, pageWidth - margin - textWidth, y); + }; - const renderHeader = (logoDataUrl?: string) => { - doc.setFontSize(18); - doc.setFont(DEFAULT_FONT, "bold"); - // Logo (if available) - if (logoDataUrl) { - const logoHeight = 24; - const logoWidth = 24; - doc.addImage(logoDataUrl, "PNG", margin, y - 16, logoWidth, logoHeight); - doc.text("Trustless Work — Escrow Audit Report", margin + logoWidth + 8, y); - } else { - doc.text("Trustless Work — Escrow Audit Report", margin, y); - } - rightText(network === "mainnet" ? "Mainnet" : "Testnet", { size: 12 }); + const renderHeader = (logoDataUrl?: string) => { + doc.setFontSize(18); + doc.setFont(DEFAULT_FONT, "bold"); + // Logo (if available) + if (logoDataUrl) { + const logoHeight = 24; + const logoWidth = 24; + doc.addImage(logoDataUrl, "PNG", margin, y - 16, logoWidth, logoHeight); + doc.text("Trustless Work — Escrow Audit Report", margin + logoWidth + 8, y); + } else { + doc.text("Trustless Work — Escrow Audit Report", margin, y); + } + rightText(network === "mainnet" ? "Mainnet" : "Testnet", { size: 12 }); + line(20); + doc.setDrawColor(41, 98, 255); + doc.setLineWidth(1); + doc.line(margin, y, pageWidth - margin, y); + line(16); + }; + + const renderFooter = () => { + const footerY = doc.internal.pageSize.getHeight() - margin / 2; + doc.setFontSize(10); + doc.setFont(DEFAULT_FONT, "normal"); + doc.setTextColor(120); + doc.text( + "https://viewer.trustlesswork.com • Source: https://github.com/trustlesswork/escrow-viewer", + margin, + footerY + ); + doc.setTextColor(0); + }; + + renderHeader(logoDataUrl || undefined); + + // Report meta + doc.setFontSize(12); + doc.setFont(DEFAULT_FONT, "normal"); + text(`Generated: ${new Date().toISOString()}`); line(20); - doc.setDrawColor(41, 98, 255); - doc.setLineWidth(1); - doc.line(margin, y, pageWidth - margin, y); - line(16); - }; - const renderFooter = () => { - const footerY = doc.internal.pageSize.getHeight() - margin / 2; - doc.setFontSize(10); + // Title & description + doc.setFontSize(16); + doc.setFont(DEFAULT_FONT, "bold"); + text(organized.title || "Escrow"); + line(16); + doc.setFontSize(12); doc.setFont(DEFAULT_FONT, "normal"); - doc.setTextColor(120); - doc.text( - "https://viewer.trustlesswork.com • Source: https://github.com/trustlesswork/escrow-viewer", - margin, - footerY - ); - doc.setTextColor(0); - }; - - renderHeader(logoDataUrl || undefined); - - // Report meta - doc.setFontSize(12); - doc.setFont(DEFAULT_FONT, "normal"); - text(`Generated: ${new Date().toISOString()}`); - line(20); - - // Title & description - doc.setFontSize(16); - doc.setFont(DEFAULT_FONT, "bold"); - text(organized.title || "Escrow"); - line(16); - doc.setFontSize(12); - doc.setFont(DEFAULT_FONT, "normal"); - text(organized.description || ""); - line(12); - - // Properties - doc.setFontSize(14); - doc.setFont(DEFAULT_FONT, "bold"); - text("Escrow Details"); - line(14); - doc.setFontSize(12); - doc.setFont(DEFAULT_FONT, "normal"); - - const props = organized.properties; - const details: Array<[string, string]> = [ - ["Escrow ID", props.escrow_id], - ["Engagement ID", String(props.engagement_id || "-")], - ["Amount", String(props.amount || "-")], - ["Balance", String(props.balance || "-")], - ["Platform Fee", String(props.platform_fee || "-")], - ["Asset (trustline)", String(props.trustline || "-")], - ]; - - details.forEach(([k, v]) => { - text(`${k}: ${v}`); - line(14); - }); - - line(6); - - // Roles - doc.setFontSize(14); - doc.setFont(DEFAULT_FONT, "bold"); - text("Assigned Roles"); - line(14); - doc.setFontSize(12); - doc.setFont(DEFAULT_FONT, "normal"); - Object.entries(organized.roles).forEach(([key, value]) => { - const label = ROLE_MAPPING[key] || key.replace(/_/g, " "); - text(`${label}: ${value}`); + text(organized.description || ""); + line(12); + + // Properties + doc.setFontSize(14); + doc.setFont(DEFAULT_FONT, "bold"); + text("Escrow Details"); line(14); - }); + doc.setFontSize(12); + doc.setFont(DEFAULT_FONT, "normal"); + + const props = organized.properties; + const details: Array<[string, string]> = [ + ["Escrow ID", props.escrow_id], + ["Engagement ID", String(props.engagement_id || "-")], + ["Amount", String(props.amount || "-")], + ["Balance", String(props.balance || "-")], + ["Platform Fee", String(props.platform_fee || "-")], + ["Asset (trustline)", String(props.trustline || "-")], + ]; - line(6); + details.forEach(([k, v]) => { + text(`${k}: ${v}`); + line(14); + }); - // Milestones - doc.setFontSize(14); - doc.setFont(DEFAULT_FONT, "bold"); - text("Milestones"); - line(14); - doc.setFontSize(12); - doc.setFont(DEFAULT_FONT, "normal"); + line(6); - if (organized.milestones.length === 0) { - text("No milestones found"); + // Roles + doc.setFontSize(14); + doc.setFont(DEFAULT_FONT, "bold"); + text("Assigned Roles"); line(14); - } else { - organized.milestones.forEach((m, idx) => { - text(`${idx + 1}. ${m.title}` , { bold: true }); + doc.setFontSize(12); + doc.setFont(DEFAULT_FONT, "normal"); + Object.entries(organized.roles).forEach(([key, value]) => { + const label = ROLE_MAPPING[key] || key.replace(/_/g, " "); + text(`${label}: ${value}`); line(14); - text(`Status: ${m.status}${m.approved ? " (approved)" : ""}`); + }); + + line(6); + + // Milestones + doc.setFontSize(14); + doc.setFont(DEFAULT_FONT, "bold"); + text("Milestones"); + line(14); + doc.setFontSize(12); + doc.setFont(DEFAULT_FONT, "normal"); + + if (organized.milestones.length === 0) { + text("No milestones found"); line(14); - if (m.amount) { - text(`Amount: ${m.amount}`); - line(14); - } - if (typeof m.signer === "string") { - text(`Signer: ${m.signer}`); + } else { + organized.milestones.forEach((m, idx) => { + text(`${idx + 1}. ${m.title}` , { bold: true }); line(14); - } - if (typeof m.approver === "string") { - text(`Approver: ${m.approver}`); + if (m.description) { + text(`Description: ${m.description}`); + line(14); + } + text(`Status: ${m.status}${m.approved ? " (approved)" : ""}`); line(14); - } - line(8); - }); - } + if (m.amount) { + text(`Amount: ${m.amount}`); + line(14); + } + if (typeof m.signer === "string") { + text(`Signer: ${m.signer}`); + line(14); + } + if (typeof m.approver === "string") { + text(`Approver: ${m.approver}`); + line(14); + } + line(8); + }); + } - // Footer on last page - renderFooter(); + // Footer on last page + renderFooter(); - // Save - const filename = `trustlesswork-escrow-${props.escrow_id}-${network}.pdf`; - doc.save(filename); + // Save + const filename = `trustlesswork-escrow-${props.escrow_id}-${network}.pdf`; + doc.save(filename); + } catch (error) { + console.error("PDF generation failed:", error); + throw new Error("Failed to generate PDF report"); + } }