Skip to content

Commit f99fe6e

Browse files
authored
feat: added pdf download (#94)
1 parent d6d613a commit f99fe6e

File tree

17 files changed

+1628
-66
lines changed

17 files changed

+1628
-66
lines changed

package-lock.json

Lines changed: 1038 additions & 49 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/invoice-dashboard/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@
3131
"@requestnetwork/payment-detection": "0.43.1-next.2043",
3232
"@requestnetwork/payment-processor": "0.45.1-next.2043",
3333
"@requestnetwork/request-client.js": "0.47.1-next.2043",
34-
"viem": "^2.9.15",
35-
"ethers": "^5.7.2"
34+
"ethers": "^5.7.2",
35+
"svelte-sonner": "^0.3.27",
36+
"viem": "^2.9.15"
3637
},
3738
"devDependencies": {
38-
"svelte": "^4.0.5",
3939
"@sveltejs/package": "^2.0.0",
4040
"@sveltejs/vite-plugin-svelte": "^2.5.2",
41+
"@tailwindcss/typography": "^0.5.13",
42+
"svelte": "^4.0.5",
4143
"svelte-check": "^3.6.0",
4244
"tslib": "^2.4.1",
4345
"typescript": "^5.0.0",

packages/invoice-dashboard/src/app.css

Lines changed: 0 additions & 11 deletions
This file was deleted.

packages/invoice-dashboard/src/lib/dashboard/invoice-view.svelte

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@
99
hasErc20Approval,
1010
} from "@requestnetwork/payment-processor";
1111
import { getPaymentNetworkExtension } from "@requestnetwork/payment-detection";
12+
import { toast } from "svelte-sonner";
1213
1314
// Components
1415
import Button from "@requestnetwork/shared-components/button.svelte";
1516
import Accordion from "@requestnetwork/shared-components/accordion.svelte";
17+
import Tooltip from "@requestnetwork/shared-components/tooltip.svelte";
1618
1719
// Icons
1820
import Check from "@requestnetwork/shared-icons/check.svelte";
21+
import Download from "@requestnetwork/shared-icons/download.svelte";
1922
2023
// Utils
2124
import { formatDate } from "@requestnetwork/shared-utils/formatDate";
@@ -24,7 +27,7 @@
2427
// Types
2528
import type { WalletState } from "@requestnetwork/shared-types/web3Onboard";
2629
27-
import { walletClientToSigner } from "../../utils";
30+
import { walletClientToSigner, exportToPDF } from "../../utils";
2831
import { formatUnits } from "viem";
2932
import { onMount } from "svelte";
3033
@@ -224,6 +227,24 @@
224227
<p class={`invoice-status ${isPaid ? "bg-green" : "bg-zinc"}`}>
225228
{isPaid ? "Paid" : "Created"}
226229
</p>
230+
<Tooltip text="Download PDF">
231+
<Download
232+
onClick={async () => {
233+
try {
234+
await exportToPDF(request, currency, config.logo);
235+
} catch (error) {
236+
toast.error(`Failed to export PDF`, {
237+
description: `${error}`,
238+
action: {
239+
label: "X",
240+
onClick: () => console.info("Close"),
241+
},
242+
});
243+
console.error("Failed to export PDF:", error);
244+
}
245+
}}
246+
/>
247+
</Tooltip>
227248
</h2>
228249
<div class="invoice-address">
229250
<h2>From:</h2>
@@ -451,9 +472,15 @@
451472
line-height: 1.75rem;
452473
font-weight: 700;
453474
display: flex;
475+
align-items: center;
454476
gap: 12px;
455477
}
456478
479+
.invoice-number svg {
480+
width: 13px;
481+
height: 13px;
482+
}
483+
457484
.invoice-status {
458485
padding-left: 0.5rem;
459486
padding-right: 0.5rem;

packages/invoice-dashboard/src/lib/view-requests.svelte

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@
99
import Dropdown from "@requestnetwork/shared-components/dropdown.svelte";
1010
import Skeleton from "@requestnetwork/shared-components/skeleton.svelte";
1111
import PoweredBy from "@requestnetwork/shared-components/powered-by.svelte";
12+
import Tooltip from "@requestnetwork/shared-components/tooltip.svelte";
13+
import Toaster from "@requestnetwork/shared-components/sonner.svelte";
14+
import { toast } from "svelte-sonner";
1215
1316
// Icons
1417
import ChevronUp from "@requestnetwork/shared-icons/chevron-up.svelte";
1518
import ChevronDown from "@requestnetwork/shared-icons/chevron-down.svelte";
1619
import ChevronLeft from "@requestnetwork/shared-icons/chevron-left.svelte";
1720
import ChevronRight from "@requestnetwork/shared-icons/chevron-right.svelte";
1821
import Search from "@requestnetwork/shared-icons/search.svelte";
22+
import Download from "@requestnetwork/shared-icons/download.svelte";
1923
2024
// Types
2125
import type { IConfig } from "@requestnetwork/shared-types";
@@ -30,7 +34,7 @@
3034
import { Drawer, InvoiceView } from "./dashboard";
3135
import { Types } from "@requestnetwork/request-client.js";
3236
import type { RequestNetwork } from "@requestnetwork/request-client.js";
33-
import { debounce, formatAddress } from "../utils";
37+
import { debounce, exportToPDF, formatAddress } from "../utils";
3438
import { CurrencyManager } from "@requestnetwork/currency";
3539
3640
export let config: IConfig;
@@ -412,6 +416,7 @@
412416
</i>
413417
</div>
414418
</th>
419+
<th></th>
415420
</tr>
416421
</thead>
417422
<tbody>
@@ -484,6 +489,32 @@
484489
?.symbol}
485490
</td>
486491
<td> {checkStatus(request)}</td>
492+
<td
493+
><Tooltip text="Download PDF">
494+
<Download
495+
onClick={async () => {
496+
try {
497+
await exportToPDF(
498+
request,
499+
currencyManager.fromAddress(
500+
request?.currencyInfo?.value
501+
),
502+
config.logo
503+
);
504+
} catch (error) {
505+
toast.error(`Failed to export PDF`, {
506+
description: `${error}`,
507+
action: {
508+
label: "X",
509+
onClick: () => console.info("Close"),
510+
},
511+
});
512+
console.error("Failed to export PDF:", error);
513+
}
514+
}}
515+
/>
516+
</Tooltip></td
517+
>
487518
</tr>
488519
{/each}
489520
{/if}
@@ -551,6 +582,7 @@
551582
</div>
552583
{/if}
553584
<PoweredBy />
585+
<Toaster />
554586
</div>
555587

556588
<style>
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { formatUnits } from "viem";
2+
import { loadScript } from "./loadScript";
3+
import { calculateItemTotal } from "@requestnetwork/shared-utils/invoiceTotals";
4+
5+
declare global {
6+
interface Window {
7+
html2pdf: any;
8+
}
9+
}
10+
11+
async function ensureHtml2PdfLoaded() {
12+
if (typeof window.html2pdf === "undefined") {
13+
await loadScript(
14+
"https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"
15+
);
16+
}
17+
}
18+
19+
export const exportToPDF = async (
20+
invoice: any,
21+
currency: any,
22+
logo: string
23+
) => {
24+
await ensureHtml2PdfLoaded();
25+
26+
const content = `
27+
<html>
28+
<head>
29+
<link rel="preconnect" href="https://fonts.googleapis.com" />
30+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
31+
<link href="https://fonts.googleapis.com/css2?family=Urbanist:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" />
32+
</head>
33+
<body>
34+
<div id="invoice" style="font-family: Urbanist, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px;">
35+
<div style="display: flex; justify-content: space-between; align-items: start;">
36+
<img src="${logo}" alt="Logo" style="width: 50px; height: 50px;">
37+
<div style="text-align: right;">
38+
<p>Issued on ${new Date(
39+
invoice.contentData.creationDate
40+
).toLocaleDateString()}</p>
41+
<p>Payment due by ${new Date(
42+
invoice.contentData.paymentTerms.dueDate
43+
).toLocaleDateString()}</p>
44+
</div>
45+
</div>
46+
47+
<h1 style="text-align: center; color: #333; font-size: 28px; font-style: bold; margin-bottom: 14px;">INVOICE #${
48+
invoice.contentData.invoiceNumber
49+
}</h1>
50+
51+
<div style="display: flex; justify-content: space-between; margin-bottom: 20px; background-color: #FBFBFB; padding: 10px;">
52+
<div>
53+
<strong>From:</strong><br>
54+
<p style="font-size: 14px">${invoice.payee.value}</p>
55+
${invoice.contentData.sellerInfo.firstName ?? ""} ${
56+
invoice.contentData.sellerInfo.lastName ?? ""
57+
}<br>
58+
${invoice.contentData.sellerInfo.address["street-address"] ?? ""}<br>
59+
${invoice.contentData.sellerInfo.address.locality ?? ""}${
60+
invoice.contentData.sellerInfo.address.locality ? "," : ""
61+
} ${invoice.contentData.sellerInfo.address["postal-code"] ?? ""}<br>
62+
${invoice.contentData.sellerInfo.address["country-name"] ?? ""}<br>
63+
${
64+
invoice.contentData.sellerInfo.taxRegistration
65+
? `VAT: ${invoice.contentData.sellerInfo.taxRegistration}`
66+
: ""
67+
}<br>
68+
</div>
69+
70+
<div>
71+
<strong>To:</strong><br>
72+
<p style="font-size: 14px">${invoice.payer.value}</p>
73+
${invoice.contentData.buyerInfo.firstName ?? ""} ${
74+
invoice.contentData.buyerInfo.lastName ?? ""
75+
}<br>
76+
${invoice.contentData.buyerInfo.address["street-address"] ?? ""}<br>
77+
${invoice.contentData.buyerInfo.address.locality ?? ""}${
78+
invoice.contentData.sellerInfo.address.locality ? "," : ""
79+
} ${invoice.contentData.buyerInfo.address["postal-code"] ?? ""}<br>
80+
${invoice.contentData.buyerInfo.address["country-name"] ?? ""}<br>
81+
${
82+
invoice.contentData.buyerInfo.taxRegistration
83+
? `VAT: ${invoice.contentData.buyerInfo.taxRegistration}`
84+
: ""
85+
}<br>
86+
</div>
87+
</div>
88+
89+
<div style="margin-bottom: 20px;">
90+
<strong>Payment Chain:</strong> ${invoice.currencyInfo.network}<br>
91+
<strong>Invoice Currency:</strong> ${invoice.currency}<br>
92+
<strong>Invoice Type:</strong> Regular Invoice
93+
</div>
94+
95+
<table style="width: 100%; border-collapse: collapse;">
96+
<thead>
97+
<tr style="background-color: #f2f2f2;">
98+
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Description</th>
99+
<th style="border: 1px solid #ddd; padding: 8px; text-align: right;">Quantity</th>
100+
<th style="border: 1px solid #ddd; padding: 8px; text-align: right;">Unit Price</th>
101+
<th style="border: 1px solid #ddd; padding: 8px; text-align: right;">Discount</th>
102+
<th style="border: 1px solid #ddd; padding: 8px; text-align: right;">Tax</th>
103+
<th style="border: 1px solid #ddd; padding: 8px; text-align: right;">Amount</th>
104+
</tr>
105+
</thead>
106+
<tbody>
107+
${invoice.contentData.invoiceItems
108+
.map(
109+
(item: any) => `
110+
<tr>
111+
<td style="border: 1px solid #ddd; padding: 8px;">${
112+
item.name
113+
}</td>
114+
<td style="border: 1px solid #ddd; padding: 8px; text-align: right;">${
115+
item.quantity
116+
}</td>
117+
<td style="border: 1px solid #ddd; padding: 8px; text-align: right;">${formatUnits(
118+
item.unitPrice,
119+
currency.decimals
120+
)}</td>
121+
<td style="border: 1px solid #ddd; padding: 8px; text-align: right;">${formatUnits(
122+
item.discount,
123+
currency.decimals
124+
)}</td>
125+
<td style="border: 1px solid #ddd; padding: 8px; text-align: right;">${
126+
item.tax.amount
127+
}%</td>
128+
<td style="border: 1px solid #ddd; padding: 8px; text-align: right;">${formatUnits(
129+
calculateItemTotal(item),
130+
currency?.decimals
131+
)}</td>
132+
</tr>
133+
`
134+
)
135+
.join("")}
136+
</tbody>
137+
<tfoot>
138+
<tr>
139+
<td colspan="5" style="border: 1px solid #ddd; padding: 8px; text-align: right;"><strong>Due:</strong></td>
140+
<td style="border: 1px solid #ddd; padding: 8px; text-align: right;"><strong>${formatUnits(
141+
invoice.expectedAmount,
142+
currency.decimals
143+
)} ${invoice.currency}</strong></td>
144+
</tr>
145+
</tfoot>
146+
</table>
147+
148+
${
149+
invoice.contentData.note
150+
? `<div style="margin-top: 20px;">
151+
<h3>Memo:</h3>
152+
<p>${invoice.contentData.note}</p>
153+
</div>`
154+
: ""
155+
}
156+
</div>
157+
</body>
158+
</html>
159+
`;
160+
161+
const opt = {
162+
margin: 10,
163+
filename: `invoice-${invoice.contentData.invoiceNumber}.PDF`,
164+
image: { type: "jpeg", quality: 0.98 },
165+
html2canvas: { scale: 2 },
166+
jsPDF: { unit: "mm", format: "a4", orientation: "portrait" },
167+
};
168+
169+
window.html2pdf().from(content).set(opt).save();
170+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { debounce } from "./debounce";
22
export { formatAddress } from "./formatAddress";
3+
export { exportToPDF } from "./generateInvoice";
34
export { publicClientToProvider, walletClientToSigner } from "./wallet-utils";
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const loadScript = (src: string): Promise<void> => {
2+
return new Promise((resolve, reject) => {
3+
const script = document.createElement("script");
4+
script.src = src;
5+
script.onload = () => resolve();
6+
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
7+
document.head.appendChild(script);
8+
});
9+
};

0 commit comments

Comments
 (0)