From e9d5d052b0c02ca1c491d932786fe9f2243a0608 Mon Sep 17 00:00:00 2001 From: Aditya8369 Date: Mon, 22 Jun 2026 10:50:15 +0530 Subject: [PATCH 1/2] export PDF after verifying token --- src/lib/__tests__/pdfExport.test.ts | 88 ++++++ src/pages/BatchVerify.tsx | 40 ++- src/utils/pdfExport.ts | 437 ++++++++++++++++++++++++++++ 3 files changed, 564 insertions(+), 1 deletion(-) create mode 100644 src/lib/__tests__/pdfExport.test.ts create mode 100644 src/utils/pdfExport.ts diff --git a/src/lib/__tests__/pdfExport.test.ts b/src/lib/__tests__/pdfExport.test.ts new file mode 100644 index 0000000..a727e2d --- /dev/null +++ b/src/lib/__tests__/pdfExport.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { exportVerifyResultToPDF } from "../../utils/pdfExport"; +import type { VerifyBatchResponse } from "../api"; +import { jsPDF } from "jspdf"; + +describe("pdfExport utility", () => { + const mockFullData: VerifyBatchResponse = { + success: true, + cached: false, + tokenId: "0.0.123456", + serialNumber: "5", + status: "verified", + verifiedAt: "2026-06-22T10:00:00.000Z", + nftMetadata: { name: "Batch #5 NFT" }, + hcsTransactionIds: [ + "0.0.123@1234567890.000000001", + "0.0.123@1234567890.000000002" + ], + hcsMessages: [], + ai_summary: { + summary_en: "This batch contains organic premium coffee from Rwanda, fully verified on ledger.", + summary_fr: "Ce lot contient du café biologique premium du Rwanda, entièrement vérifié sur le registre.", + trustScore: 92, + trustExplanation: "All metadata matches the HCS ledger transactions and supply chain checkpoints perfectly.", + timeline: [ + { + timestamp: "2026-06-20T08:00:00.000Z", + event: "Harvested arabica beans in Rwanda", + txId: "0.0.123@1234567890.000000001" + }, + { + timestamp: "2026-06-21T10:00:00.000Z", + event: "Processed and packaged at cooperative", + txId: "0.0.123@1234567890.000000002" + } + ], + generatedAt: "2026-06-22T10:00:00.000Z", + ms: 120 + }, + batch: { + id: "batch-uuid-111", + batch_name: "Kigali Select Arabica", + product_type: "Coffee Beans", + quantity: "250 kg", + location: "Kigali, Rwanda", + harvest_date: "2026-06-20", + photo_url: "https://example.com/photo.jpg", + hcs_tx_id: "0.0.123@1234567890.000000001", + created_at: "2026-06-22T10:00:00.000Z" + } + }; + + const mockMinimalData: VerifyBatchResponse = { + success: true, + cached: true, + tokenId: "0.0.789", + serialNumber: "1", + status: "registered", + verifiedAt: "2026-06-22T10:30:00.000Z", + nftMetadata: null, + hcsTransactionIds: [], + hcsMessages: [] + }; + + it("should successfully generate a PDF document from full verified batch data", () => { + const doc = exportVerifyResultToPDF(mockFullData, { + qrCodeDataUrl: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", + language: "en" + }); + + expect(typeof doc.save).toBe("function"); + expect(typeof doc.output).toBe("function"); + expect(doc.internal.getNumberOfPages()).toBeGreaterThanOrEqual(1); + }); + + it("should successfully generate a PDF document with minimal verified batch data", () => { + const doc = exportVerifyResultToPDF(mockMinimalData); + + expect(typeof doc.save).toBe("function"); + expect(typeof doc.output).toBe("function"); + expect(doc.internal.getNumberOfPages()).toBe(1); + }); + + it("should support French language summary if selected", () => { + const doc = exportVerifyResultToPDF(mockFullData, { language: "fr" }); + expect(typeof doc.save).toBe("function"); + }); +}); diff --git a/src/pages/BatchVerify.tsx b/src/pages/BatchVerify.tsx index 6a83d33..32baaa3 100644 --- a/src/pages/BatchVerify.tsx +++ b/src/pages/BatchVerify.tsx @@ -35,6 +35,7 @@ import { Calendar, Download, Camera, + FileText, } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import axios from "axios"; @@ -42,6 +43,7 @@ import Navbar from "@/components/Navbar"; import Footer from "@/components/Footer"; import { Helmet } from "react-helmet-async"; import { CopyButton } from "@/components/CopyButton"; +import { exportVerifyResultToPDF } from "@/utils/pdfExport"; type NotFoundResult = Extract; @@ -235,6 +237,33 @@ export default function BatchVerify() { } }; + const handleExportPDF = () => { + if (!verifiedResult) return; + try { + const canvas = document.getElementById("verify-qr-canvas") as HTMLCanvasElement; + const qrCodeDataUrl = canvas?.toDataURL("image/png"); + + const doc = exportVerifyResultToPDF(verifiedResult, { + qrCodeDataUrl, + language, + }); + + const filenameId = verifiedResult.batch?.id || params.batchId || `${verifiedResult.tokenId}_${verifiedResult.serialNumber}`; + doc.save(`agrodex-certificate-${filenameId}.pdf`); + + toast({ + title: "PDF Exported", + description: "Your verification certificate has been downloaded.", + }); + } catch (err: any) { + toast({ + title: "Export Error", + description: err.message || "Failed to generate PDF.", + variant: "destructive", + }); + } + }; + return (
@@ -450,10 +479,19 @@ export default function BatchVerify() { {/* Only show details if batch was found */} {verifiedResult && ( - + Verification Details +
diff --git a/src/utils/pdfExport.ts b/src/utils/pdfExport.ts new file mode 100644 index 0000000..b7a3b92 --- /dev/null +++ b/src/utils/pdfExport.ts @@ -0,0 +1,437 @@ +import { jsPDF } from "jspdf"; +import type { VerifyBatchResponse } from "@/lib/api"; + +export interface PDFExportOptions { + qrCodeDataUrl?: string; + language?: "en" | "fr"; +} + +export function exportVerifyResultToPDF( + data: VerifyBatchResponse, + options: PDFExportOptions = {} +) { + const { qrCodeDataUrl, language = "en" } = options; + const doc = new jsPDF({ + orientation: "portrait", + unit: "mm", + format: "a4", + }); + + const pageHeight = 297; + const pageWidth = 210; + const margin = 20; + const contentWidth = pageWidth - 2 * margin; // 170mm + const marginBottom = 20; + let y = 15; + + // Helper to check page bounds and auto-add new page + const checkPageBounds = (neededHeight: number) => { + if (y + neededHeight > pageHeight - marginBottom) { + doc.addPage(); + drawPageFooter(); + y = 20; + return true; + } + return false; + }; + + // Helper to draw the standard footer on every page + const drawPageFooter = () => { + const totalPages = (doc as any).internal.getNumberOfPages(); + for (let i = 1; i <= totalPages; i++) { + doc.setPage(i); + doc.setFont("helvetica", "normal"); + doc.setFontSize(8); + doc.setTextColor(100, 116, 139); // Slate-500 + + // Bottom border line + doc.setDrawColor(226, 232, 240); // Slate-200 + doc.setLineWidth(0.2); + doc.line(margin, pageHeight - 15, pageWidth - margin, pageHeight - 15); + + // Footer text + doc.text("AgroDex Provenance Certificate • Confidential & Verified Registry Record", margin, pageHeight - 10); + doc.text(`Page ${i} of ${totalPages}`, pageWidth - margin, pageHeight - 10, { align: "right" }); + } + }; + + // 1. PAGE 1 HEADER BANNER + // Slate dark header banner background + doc.setFillColor(15, 23, 42); // slate-900 (#0f172a) + doc.rect(0, 0, pageWidth, 45, "F"); + + // Title + doc.setFont("helvetica", "bold"); + doc.setFontSize(16); + doc.setTextColor(255, 255, 255); + doc.text("AGRODEX VERIFICATION CERTIFICATE", margin, 18); + + // Subtitle + doc.setFont("helvetica", "normal"); + doc.setFontSize(10); + doc.setTextColor(148, 163, 184); // slate-400 + doc.text("Decentralized Agricultural Provenance & Ledger Registry", margin, 25); + + // Issuance time + doc.setFont("helvetica", "italic"); + doc.setFontSize(8); + doc.setTextColor(203, 213, 225); // slate-300 + const issueDateStr = new Date(data.verifiedAt || Date.now()).toLocaleString(); + doc.text(`Verification Issued: ${issueDateStr}`, margin, 34); + + // Status Badge in Banner + const statusStr = (data.status || "verified").toUpperCase(); + const isVerified = statusStr.includes("VERIFIED") || statusStr.includes("REGISTERED"); + + // Badge box + const badgeWidth = 28; + const badgeHeight = 6; + const badgeX = margin; + const badgeY = 37; + + if (isVerified) { + doc.setFillColor(16, 185, 129); // emerald-500 (#10b981) + } else { + doc.setFillColor(37, 99, 235); // blue-600 (#2563eb) + } + doc.rect(badgeX, badgeY, badgeWidth, badgeHeight, "F"); + + doc.setFont("helvetica", "bold"); + doc.setFontSize(7); + doc.setTextColor(255, 255, 255); + doc.text(statusStr, badgeX + badgeWidth / 2, badgeY + 4.2, { align: "center" }); + + // Embed QR Code in Top Right Banner if provided + if (qrCodeDataUrl) { + doc.setFillColor(255, 255, 255); + // Draw white container box + doc.rect(pageWidth - margin - 26, 8, 26, 26, "F"); + // Draw QR image + doc.addImage(qrCodeDataUrl, "PNG", pageWidth - margin - 25, 9, 24, 24); + } + + y = 55; + + // 2. VERIFICATION CREDENTIALS & METADATA + doc.setFont("helvetica", "bold"); + doc.setFontSize(12); + doc.setTextColor(30, 58, 138); // blue-900 (#1e3a8a) + doc.text("1. Blockchain Registry Metadata", margin, y); + + y += 3; + doc.setDrawColor(219, 234, 254); // blue-100 + doc.setLineWidth(0.4); + doc.line(margin, y, pageWidth - margin, y); + + y += 6; + + // Metadata 2-column key-value grid + const col1X = margin; + const col2X = margin + 85; + const rowHeight = 6; + + const metadataPairs = [ + { label: "Token ID", value: data.tokenId || "Pending Tokenization (Registered)", isCol2: false }, + { label: "Serial Number", value: data.serialNumber || "N/A", isCol2: false }, + { label: "Status", value: data.status || "Registered", isCol2: false }, + { label: "Registry Database ID", value: data.batch?.id || "N/A", isCol2: true }, + { label: "Verification Ledger", value: "Hedera Testnet HCS", isCol2: true }, + { label: "Verified Timestamp", value: issueDateStr, isCol2: true } + ]; + + doc.setFont("helvetica", "normal"); + doc.setFontSize(9); + + let c1Y = y; + let c2Y = y; + + metadataPairs.forEach((pair) => { + if (pair.isCol2) { + doc.setFont("helvetica", "bold"); + doc.setTextColor(71, 85, 105); // slate-600 + doc.text(`${pair.label}:`, col2X, c2Y); + doc.setFont("helvetica", "normal"); + doc.setTextColor(15, 23, 42); // slate-900 + doc.text(String(pair.value), col2X + 32, c2Y); + c2Y += rowHeight; + } else { + doc.setFont("helvetica", "bold"); + doc.setTextColor(71, 85, 105); // slate-600 + doc.text(`${pair.label}:`, col1X, c1Y); + doc.setFont("helvetica", "normal"); + doc.setTextColor(15, 23, 42); // slate-900 + + // Handle Token ID wrapping/clipping if it's too long + const valStr = String(pair.value); + if (valStr.length > 25) { + doc.text(valStr.substring(0, 25) + "...", col1X + 26, c1Y); + } else { + doc.text(valStr, col1X + 26, c1Y); + } + c1Y += rowHeight; + } + }); + + y = Math.max(c1Y, c2Y) + 4; + + // 3. PRODUCT & HARVEST DETAILS (If batch details exist) + if (data.batch) { + checkPageBounds(40); + + doc.setFont("helvetica", "bold"); + doc.setFontSize(12); + doc.setTextColor(30, 58, 138); // blue-900 + doc.text("2. Product & Harvest Details", margin, y); + + y += 3; + doc.setDrawColor(219, 234, 254); + doc.line(margin, y, pageWidth - margin, y); + + y += 6; + + const batchPairs = [ + { label: "Product/Batch Name", value: data.batch.batch_name || "N/A", isCol2: false }, + { label: "Product Type", value: data.batch.product_type || "N/A", isCol2: false }, + { label: "Quantity / Volume", value: data.batch.quantity || "N/A", isCol2: false }, + { label: "Harvest Location", value: data.batch.location || "N/A", isCol2: true }, + { label: "Harvest Date", value: data.batch.harvest_date || "N/A", isCol2: true }, + { label: "Creation Ledger ID", value: data.batch.hcs_tx_id ? data.batch.hcs_tx_id.substring(0, 20) + "..." : "N/A", isCol2: true } + ]; + + let b1Y = y; + let b2Y = y; + + batchPairs.forEach((pair) => { + if (pair.isCol2) { + doc.setFont("helvetica", "bold"); + doc.setTextColor(71, 85, 105); + doc.text(`${pair.label}:`, col2X, b2Y); + doc.setFont("helvetica", "normal"); + doc.setTextColor(15, 23, 42); + + // Wrap location if it's too long + const valStr = String(pair.value); + if (pair.label === "Harvest Location" && valStr.length > 25) { + doc.text(valStr.substring(0, 25) + "...", col2X + 32, b2Y); + } else { + doc.text(valStr, col2X + 32, b2Y); + } + b2Y += rowHeight; + } else { + doc.setFont("helvetica", "bold"); + doc.setTextColor(71, 85, 105); + doc.text(`${pair.label}:`, col1X, b1Y); + doc.setFont("helvetica", "normal"); + doc.setTextColor(15, 23, 42); + doc.text(String(pair.value), col1X + 36, b1Y); + b1Y += rowHeight; + } + }); + + y = Math.max(b1Y, b2Y) + 4; + } + + // 4. AI PROVENANCE & TRUST ANALYSIS + if (data.ai_summary) { + const hasTrustScore = data.ai_summary.trustScore !== undefined && data.ai_summary.trustScore !== null; + let estimateBlockHeight = 45; + + // Add size estimation based on text lengths + const summaryText = language === "en" ? data.ai_summary.summary_en : data.ai_summary.summary_fr; + const summaryLinesCount = doc.splitTextToSize(summaryText || "", contentWidth).length; + const explanationLinesCount = doc.splitTextToSize(data.ai_summary.trustExplanation || "", contentWidth).length; + estimateBlockHeight += (summaryLinesCount + explanationLinesCount) * 5; + + checkPageBounds(estimateBlockHeight); + + doc.setFont("helvetica", "bold"); + doc.setFontSize(12); + doc.setTextColor(30, 58, 138); + doc.text("3. AI Provenance & Trust Analysis", margin, y); + + y += 3; + doc.setDrawColor(219, 234, 254); + doc.line(margin, y, pageWidth - margin, y); + + y += 6; + + // Trust Score Badge / Meter + if (hasTrustScore) { + const score = data.ai_summary.trustScore; + doc.setFillColor(248, 250, 252); // slate-50 + doc.setDrawColor(226, 232, 240); // slate-200 + doc.setLineWidth(0.3); + doc.rect(margin, y, contentWidth, 14, "FD"); + + // Score Text + doc.setFont("helvetica", "bold"); + doc.setFontSize(11); + + // Color code score text + if (score >= 80) { + doc.setTextColor(5, 150, 105); // emerald-600 + } else if (score >= 50) { + doc.setTextColor(217, 119, 6); // amber-600 + } else { + doc.setTextColor(220, 38, 38); // red-600 + } + doc.text(`Trust Score: ${score}/100`, margin + 5, y + 9); + + // Trust score visual progress bar + const barX = margin + 70; + const barY = y + 5; + const barWidth = 80; + const barHeight = 4; + + // Gray background bar + doc.setFillColor(226, 232, 240); // slate-200 + doc.rect(barX, barY, barWidth, barHeight, "F"); + + // Filled progress bar + if (score >= 80) { + doc.setFillColor(16, 185, 129); // emerald-500 + } else if (score >= 50) { + doc.setFillColor(245, 158, 11); // amber-500 + } else { + doc.setFillColor(239, 68, 68); // red-500 + } + const filledWidth = Math.max(1, Math.min(barWidth, barWidth * (score / 100))); + doc.rect(barX, barY, filledWidth, barHeight, "F"); + + y += 18; + + // Trust Explanation + if (data.ai_summary.trustExplanation) { + doc.setFont("helvetica", "bold"); + doc.setFontSize(9); + doc.setTextColor(71, 85, 105); + doc.text("Analysis Evaluation:", margin, y); + y += 4; + + doc.setFont("helvetica", "normal"); + doc.setFontSize(9); + doc.setTextColor(15, 23, 42); + + const explanationText = data.ai_summary.trustExplanation; + const explanationLines = doc.splitTextToSize(explanationText, contentWidth); + explanationLines.forEach((line: string) => { + checkPageBounds(6); + doc.text(line, margin, y); + y += 5; + }); + y += 2; + } + } + + // AI Summary Text + if (summaryText) { + checkPageBounds(15); + doc.setFont("helvetica", "bold"); + doc.setFontSize(9); + doc.setTextColor(71, 85, 105); + doc.text("AI Generated Provenance Summary:", margin, y); + y += 5; + + doc.setFont("helvetica", "normal"); + doc.setFontSize(9); + doc.setTextColor(15, 23, 42); + + const summaryLines = doc.splitTextToSize(summaryText, contentWidth); + summaryLines.forEach((line: string) => { + checkPageBounds(6); + doc.text(line, margin, y); + y += 5; + }); + y += 3; + } + } + + // 5. SUPPLY CHAIN TIMELINE + if (data.ai_summary?.timeline && data.ai_summary.timeline.length > 0) { + const timeline = data.ai_summary.timeline; + checkPageBounds(30); + + doc.setFont("helvetica", "bold"); + doc.setFontSize(12); + doc.setTextColor(30, 58, 138); + doc.text("4. Supply Chain History Trail", margin, y); + + y += 3; + doc.setDrawColor(219, 234, 254); + doc.line(margin, y, pageWidth - margin, y); + + y += 8; + + const timelineStartX = margin + 5; + const contentStartX = margin + 15; + + // Draw events + timeline.forEach((item, idx) => { + const itemHeight = 18; + + // Check if we need to split page for this event + const pageAdded = checkPageBounds(itemHeight); + + doc.setFont("helvetica", "bold"); + doc.setFontSize(9.5); + doc.setTextColor(15, 23, 42); + doc.text(item.event, contentStartX, y); + + // Event Timestamp + doc.setFont("helvetica", "normal"); + doc.setFontSize(8); + doc.setTextColor(100, 116, 139); + const dateStr = new Date(item.timestamp).toLocaleString(); + doc.text(`Date/Time: ${dateStr}`, contentStartX, y + 4.5); + + // HCS Tx ID + doc.setFont("courier", "normal"); + doc.setFontSize(7.5); + doc.setTextColor(79, 70, 229); // indigo-600 + doc.text(`Ledger Tx: ${item.txId}`, contentStartX, y + 8.5); + + // Visual timeline markers (vertical line and circles) + doc.setDrawColor(191, 219, 254); // blue-200 + doc.setLineWidth(0.6); + + // Draw connector line if not the last item + if (idx < timeline.length - 1) { + doc.line(timelineStartX, y, timelineStartX, y + itemHeight); + } + + // Draw node circle + doc.setFillColor(37, 99, 235); // blue-600 + doc.circle(timelineStartX, y - 1, 2, "FD"); + + y += itemHeight; + }); + + y += 2; + } + + // 6. TRANSACTION TRAIL SUMMARY (If any HCS Transaction IDs exist and timeline is not present/different) + if (data.hcsTransactionIds && data.hcsTransactionIds.length > 0) { + checkPageBounds(25); + + doc.setFont("helvetica", "bold"); + doc.setFontSize(10); + doc.setTextColor(71, 85, 105); + doc.text("Blockchain HCS Messages Trail", margin, y); + y += 5; + + doc.setFont("courier", "normal"); + doc.setFontSize(7.5); + doc.setTextColor(51, 65, 85); + + data.hcsTransactionIds.forEach((txId) => { + checkPageBounds(5); + doc.text(`• ${txId}`, margin + 3, y); + y += 4.5; + }); + } + + // Draw final footer page numbers + drawPageFooter(); + + return doc; +} From 6d0a7d2c331ddb7c9b8737054a030759e922546b Mon Sep 17 00:00:00 2001 From: Aditya8369 Date: Mon, 22 Jun 2026 10:54:45 +0530 Subject: [PATCH 2/2] frontend CI fixed --- src/lib/__tests__/pdfExport.test.ts | 1 - src/pages/BatchVerify.tsx | 5 +++-- src/utils/pdfExport.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/__tests__/pdfExport.test.ts b/src/lib/__tests__/pdfExport.test.ts index a727e2d..597125d 100644 --- a/src/lib/__tests__/pdfExport.test.ts +++ b/src/lib/__tests__/pdfExport.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from "vitest"; import { exportVerifyResultToPDF } from "../../utils/pdfExport"; import type { VerifyBatchResponse } from "../api"; -import { jsPDF } from "jspdf"; describe("pdfExport utility", () => { const mockFullData: VerifyBatchResponse = { diff --git a/src/pages/BatchVerify.tsx b/src/pages/BatchVerify.tsx index 32baaa3..e51c7ec 100644 --- a/src/pages/BatchVerify.tsx +++ b/src/pages/BatchVerify.tsx @@ -255,10 +255,11 @@ export default function BatchVerify() { title: "PDF Exported", description: "Your verification certificate has been downloaded.", }); - } catch (err: any) { + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to generate PDF."; toast({ title: "Export Error", - description: err.message || "Failed to generate PDF.", + description: message, variant: "destructive", }); } diff --git a/src/utils/pdfExport.ts b/src/utils/pdfExport.ts index b7a3b92..afc76a6 100644 --- a/src/utils/pdfExport.ts +++ b/src/utils/pdfExport.ts @@ -37,7 +37,7 @@ export function exportVerifyResultToPDF( // Helper to draw the standard footer on every page const drawPageFooter = () => { - const totalPages = (doc as any).internal.getNumberOfPages(); + const totalPages = doc.getNumberOfPages(); for (let i = 1; i <= totalPages; i++) { doc.setPage(i); doc.setFont("helvetica", "normal"); @@ -370,7 +370,7 @@ export function exportVerifyResultToPDF( const itemHeight = 18; // Check if we need to split page for this event - const pageAdded = checkPageBounds(itemHeight); + checkPageBounds(itemHeight); doc.setFont("helvetica", "bold"); doc.setFontSize(9.5);