I have noticed a common gripe among forum members regarding the disabling of HTML injections inside fields.
Although it is possible now to use AI to write the custom theme from scratch but it’s possible that for for some people, myslef included, I find it more comforting to rewire the old skeleton to the new API4 connection and keep all my previous theme work as-is.
That is what I did, and I thought I share it with the forum.
Custom Theme
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Styling for consistent look and feel -->
<style>
:root { --bg: #f5f5f5; --paper: #fff; }
/* Do not change the body background-color: this page is loaded inside an
iframe whose parent uses the same --bg, so they blend seamlessly. A
different color here would expose the iframe as a visible panel. */
body { background-color: var(--bg); display: flex; margin: 0; padding: 0; }
main { background-color: var(--paper); margin: 1rem auto; border: 1px solid #e5e5e5; border-radius: 5px; box-shadow: 0px 4px 6px rgb(0 0 0 / 0.05); padding: 2rem; min-width: 800px; }
@media (prefers-color-scheme: dark) {
:root { --bg: #292524; --paper: #1c1917; }
main { color: #ccc; border: 1px solid #0c0a09; box-shadow: 0px 4px 6px rgb(0 0 0 / 0.50); }
}
@page { margin: 10mm; }
@media print {
body { background-color: initial; display: initial; }
main { padding: 0; border: initial; box-shadow: initial; border-radius: initial; margin: 0; min-width: initial; }
}
*, ::after, ::before, ::backdrop, ::file-selector-button {
margin: 0;
padding: 0;
}
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
border: 0 solid;
}
body {
margin: 0;
padding: 30px;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 12px;
color: #111;
line-height: 1.428571429;
min-width: 800px;
}
table {
font-size: 12px;
}
tr#table-headers th {
font-weight: bold;
padding: 5px 10px;
border-width: 1px;
text-align: start
}
tbody#table-rows td {
padding: 5px 10px;
border-left-width: 1px;
border-right-width: 1px;
text-align: start;
vertical-align: top
}
tbody#table-rows tr:last-child td {
padding-bottom: 30px;
border-bottom-width: 1px;
}
</style>
</head>
<body>
<main>
<!-- Top section: document title and optional business logo -->
<table style="margin-bottom: 20px; width: 100%">
<tbody>
<tr>
<td style="vertical-align: top">
<div style="font-size: 32px; line-height: 32px; font-weight: bold" id="title">Invoice</div>
</td>
<td style="text-align: end" id="business-logo">
</td>
</tr>
</tbody>
</table>
<!-- Second section: recipient info, document fields (like date, invoice #), and business info -->
<table style="margin-bottom: 20px; width: 100%">
<tbody>
<tr>
<td style="vertical-align: top" id="recipient-info"></td>
<td style="text-align: end; vertical-align: top" id="fields"></td>
<td style="width: 20px"></td>
<td style="width: 1px; border-left-width: 1px;"></td>
<td style="width: 20px"></td>
<td style="width: 1px; white-space: nowrap; vertical-align: top" id="business-info"></td>
</tr>
</tbody>
</table>
<!-- Description block -->
<div style="font-weight: bold; font-size: 14px; margin-bottom: 20px" id="description"></div>
<!-- Main table containing column headers, line items, and totals -->
<table style="border-collapse: collapse; width: 100%">
<thead>
<tr id="table-headers"></tr>
</thead>
<tbody id="table-rows">
</tbody>
<tbody id="totals">
</tbody>
</table>
<div id="qrcode" style="margin-bottom: 20px"></div>
<!-- Section for any additional custom fields -->
<div id="custom-fields"></div>
<!-- Section for footers -->
<table><tr><td><div id="footers"></div></td></tr></table>
<!-- Section for final status (e.g. PAID, VOID) with special styling -->
<div id="status" style="text-align: center"></div>
</main>
<script>
fetch('/api4/view-v1' + window.location.search, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
})
.then(response => response.json())
.then(data => {
renderDocument(data);
});
function renderDocument(data) {
if (!data) return;
console.log(data); //debugging
document.documentElement.dir = data.direction;
document.title = [data?.business?.name, data?.title, data?.reference].filter(Boolean).join(' - ');
// Populate title and description if provided
document.getElementById("title").innerHTML = data.title || "Document";
document.getElementById("description").innerHTML = data.description || "";
// Inject business logo image if available
var businessLogoTd = document.getElementById("business-logo");
if (data.business.logo) {
const img = document.createElement("img");
img.src = data.business.logo;
img.style = "max-height: 150px; max-width: 300px; display: inline";
businessLogoTd.appendChild(img);
}
// Populate business info section with name and address (convert line breaks)
const business = data.business || {};
document.getElementById("business-info").innerHTML = `<strong>${business.name || ""}</strong><br>${business.address ? business.address.replace(/\n/g, "<br>") : ""}`;
// Populate recipient info section with name and address (convert line breaks)
const recipient = data.recipient || {};
document.getElementById("recipient-info").innerHTML = `<strong>${recipient.name || ""}</strong><br>${recipient.address ? recipient.address.replace(/\n/g, "<br>") : ""}`;
// Insert fields (e.g. issue date, due date) into right-hand side
const fieldsDiv = document.getElementById("fields");
let customFields = [];
fieldsDiv.innerHTML = "";
(data.fields || []).forEach(f => {
if (f.displayAtTheTop === true) {
const div = document.createElement("div");
div.innerHTML = `<strong>${f.label}</strong><br />${f.text || ""}<br /><br />`;
fieldsDiv.appendChild(div);
} else {
customFields.push(f);
}
});
// Build table headers dynamically based on `columns` definition
const headersRow = document.getElementById("table-headers");
headersRow.innerHTML = "";
(data.table.columns || []).forEach(col => {
const th = document.createElement("th");
th.innerHTML = col.label;
th.style.textAlign = col.align;
if (col.minWidth) {
th.style.whiteSpace = 'nowrap';
th.style.width = '1px';
}
else if (col.nowrap) {
th.style.whiteSpace = 'nowrap';
th.style.width = '80px';
}
headersRow.appendChild(th);
});
// Populate main table body with rows and alignments based on column definitions
const rowsBody = document.getElementById("table-rows");
rowsBody.innerHTML = "";
(data.table.rows || []).forEach(row => {
const tr = document.createElement("tr");
row.cells.forEach((cell, i) => {
var col = data.table.columns[i];
const td = document.createElement("td");
td.innerHTML = (cell.text || "").split("\n").join("<br />");
td.style.textAlign = col.align;
if (col.minWidth) {
td.style.whiteSpace = 'nowrap';
td.style.width = '1px';
}
else if (col.nowrap) {
td.style.whiteSpace = 'nowrap';
td.style.width = '80px';
}
tr.appendChild(td);
});
rowsBody.appendChild(tr);
});
// Populate totals section with subtotals, taxes, grand total, etc.
const totalsBody = document.getElementById("totals");
totalsBody.innerHTML = "";
(data.totals || []).forEach(total => {
const tr = document.createElement("tr");
tr.className = 'total';
const tdLabel = document.createElement("td");
tdLabel.innerHTML = total.label;
tdLabel.colSpan = data.table.columns.length - 1; // Span all columns except last
tdLabel.style = 'padding: 5px 10px; text-align: end; vertical-align: top';
const tdValue = document.createElement("td");
tdValue.innerHTML = total.text; // Formatted amount
tdValue.id = total.key; // ID for targeting specific totals (e.g., 'Total')
if (total.class) tdValue.classList.add(total.class); // CSS class (e.g., 'taxAmount')
tdValue.dataset.value = total.number; // Raw numeric value for calculations
tdValue.style = 'padding: 5px 10px; border-width: 1px; text-align: right; white-space: nowrap; vertical-align: top';
// Bold totals if marked as 'emphasis'
if (total.emphasis) {
tdLabel.style.fontWeight = 'bold';
tdValue.style.fontWeight = 'bold';
}
tr.appendChild(tdLabel);
tr.appendChild(tdValue);
totalsBody.appendChild(tr);
});
// Render custom fields section below the table (e.g. notes, terms)
const customFieldsDiv = document.getElementById("custom-fields");
customFieldsDiv.innerHTML = "";
(customFields || []).forEach(f => {
const div = document.createElement("div");
div.innerHTML = `<strong>${f.label || ""}</strong><br />${(f.text || "").split("\n").join("<br />")}<br /><br />`;
customFieldsDiv.appendChild(div);
});
// Render footers section
const footersDiv = document.getElementById("footers");
footersDiv.innerHTML = "";
(data.footers || []).forEach(f => {
const div = document.createElement("div");
div.style = 'margin-top: 20px';
div.innerHTML = f;
footersDiv.appendChild(div);
// Find and execute any script tags
const scripts = div.querySelectorAll("script");
scripts.forEach(script => {
const newScript = document.createElement("script");
// Copy script attributes if needed
for (const attr of script.attributes) {
newScript.setAttribute(attr.name, attr.value);
}
// Inline script handling
if (script.textContent) {
newScript.textContent = script.textContent;
}
// Replace the old <script> with the new one so it executes
script.parentNode.replaceChild(newScript, script);
});
});
// Display status label (e.g. PAID, CANCELLED) with colored border based on status type
const statusDiv = document.getElementById("status");
if (data.emphasis?.text != null) {
statusDiv.style.marginTop = '40px';
const span = document.createElement("span");
span.style = 'border-width: 5px; border-color: #FF0000; border-style: solid; padding: 10px; font-size: 20px; text-transform: uppercase';
if (data.emphasis.positive) {
span.style.color = 'green';
span.style.borderColor = 'green';
}
if (data.emphasis.negative) {
span.style.color = 'red';
span.style.borderColor = 'red';
}
span.innerHTML = data.emphasis.text;
statusDiv.appendChild(span);
}
/*
// This handles QR code on invoices for Saudi Arabia - this is here just temporarily. Better way will be implemented.
if (data.legacyQrCodeForSaudiArabia) {
function appendTLV(tag, text, byteList) {
const encoded = new TextEncoder().encode(text);
byteList.push(tag);
byteList.push(encoded.length);
for (let b of encoded) byteList.push(b);
}
const byteList = [];
let businessName = 'No name';
if (data.business) businessName = data.business.name;
let vatNumber = '0000000000000';
let vatField = data.business.custom_fields.find(item => item.key === "d96d97e8-c857-42c6-8360-443c06a13de9");
if (vatField) vatNumber = vatField.text;
let timestamp = new Date((data.timestamp - 621355968000000000) / 10000).toISOString();
let total = 0;
let totalElement = document.getElementById('Total');
if (totalElement != null) total = parseFloat(totalElement.getAttribute('data-value'));
let vat = 0;
let taxAmounts = document.getElementsByClassName('taxAmount');
for (let i = 0; i < taxAmounts.length; i++) {
vat += parseFloat(taxAmounts[i].getAttribute('data-value'));
}
appendTLV(1, businessName, byteList);
appendTLV(2, vatNumber, byteList);
appendTLV(3, timestamp, byteList);
appendTLV(4, total.toFixed(2), byteList);
appendTLV(5, vat.toFixed(2), byteList);
// Convert to Uint8Array
const tlvBytes = Uint8Array.from(byteList);
// Convert to Base64
const qrData = btoa(String.fromCharCode(...tlvBytes));
new QRCode(document.getElementById("qrcode"), {
text: qrData,
width: 128,
height: 128,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.L
});
}
*/
}
</script>
</body>
</html>
It’s not perfect by any means, in fact I know that there are some ineffective remnants from the old messaging theme, but it’s a good place to start.
From here you have a sturdier skeleton from which you can ask AI to add more on top like amount in words, or real-time customer balances to name a few; or just dump your previous stylesheets onto this theme to future proof your previous work.