{
b.classList.toggle("active", b.dataset.status === state.status);
});
}
function wireStatic(){
bindText("fromName","from.name");
bindText("fromEmail","from.email");
bindText("fromAddr","from.addr");
bindText("toName","to.name");
bindText("toEmail","to.email");
bindText("toAddr","to.addr");
bindText("invNumber","number");
bindText("issueDate","issue");
bindText("dueDate","due");
bindText("currency","currency");
bindText("notes","notes");
$("taxRate").addEventListener("input", e => { state.taxRate = parseFloat(e.target.value)||0; render(); });
document.querySelectorAll(".status-toggle button").forEach(b=>{
b.addEventListener("click", () => {
state.status = b.dataset.status;
document.querySelectorAll(".status-toggle button").forEach(x=>x.classList.remove("active"));
b.classList.add("active");
render();
});
});
$("addItemBtn").addEventListener("click", () => {
state.items.push({desc:"", qty:1, rate:0});
renderItemsForm();
render();
});
$("newBtn").addEventListener("click", () => {
if(!confirm("Start a new invoice? This clears the current one.")) return;
Object.assign(state, {
from:{name:"",email:"",addr:""}, to:{name:"",email:"",addr:""},
number:"", issue:"", due:"", currency:"$", taxRate:0, status:"DRAFT",
notes:"", items:[{desc:"",qty:1,rate:0}]
});
hydrateInputs();
renderItemsForm();
render();
});
$("printBtn").addEventListener("click", () => { logDocument(); window.print(); const ns=$("nextSteps"); if(ns) ns.style.display="flex"; });
const emailLink = $("emailInvoiceLink");
if(emailLink){
emailLink.addEventListener("click", (e) => {
e.preventDefault();
const toName = (state.to && state.to.name) || "there";
const toEmail = (state.to && state.to.email) || "";
const fromName = (state.from && state.from.name) || "me";
const num = state.number ? ` #${state.number}` : "";
const subject = encodeURIComponent(`Invoice${num} from ${fromName}`);
const body = encodeURIComponent(`Hi ${toName},\n\nSharing invoice${num} for the work discussed. I've downloaded the PDF, attaching it here.\n\nThanks,\n${fromName}\n\n(Made with Ledgerline, free invoicing tools that never leave your browser: https://obscuraos.com/?ref=email)`);
window.location.href = `mailto:${toEmail}?subject=${subject}&body=${body}`;
});
}
$("logoInput").addEventListener("change", (e) => {
const file = e.target.files && e.target.files[0];
if(!file) return;
if(file.size > 900*1024){ alert("Please use an image under 900KB."); return; }
const reader = new FileReader();
reader.onload = () => {
try{ localStorage.setItem("ledgerline_logo", reader.result); }catch(err){ alert("Couldn't save that image, try a smaller file."); return; }
render();
};
reader.readAsDataURL(file);
});
$("logoClearBtn").addEventListener("click", () => {
localStorage.removeItem("ledgerline_logo");
$("logoInput").value = "";
render();
});
$("proBtn").addEventListener("click", () => {
if(state.pro){ alert("Pro is already unlocked on this device."); return; }
if(CONFIG.STRIPE_PAYMENT_LINK){
window.location.href = "/ledgerline.html#pricing";
} else {
alert("Owner hasn't connected a Stripe Payment Link yet, set CONFIG.STRIPE_PAYMENT_LINK in the page source.");
}
});
}
function renderItemsForm(){
const body = $("itemsBody");
body.innerHTML = "";
state.items.forEach((item, idx) => {
const tr = document.createElement("tr");
tr.innerHTML =
' | ' +
' | ' +
' | ' +
''+fmt((item.qty||0)*(item.rate||0), state.currency)+' | ' +
' | ';
body.appendChild(tr);
});
body.querySelectorAll("input").forEach(inp => {
inp.addEventListener("input", () => {
const idx = +inp.dataset.idx, f = inp.dataset.f;
state.items[idx][f] = (f === "desc") ? inp.value : (parseFloat(inp.value)||0);
render(true);
});
});
body.querySelectorAll("[data-rm]").forEach(btn => {
btn.addEventListener("click", () => {
if(state.items.length <= 1) return;
state.items.splice(+btn.dataset.rm, 1);
renderItemsForm();
render();
});
});
}
function escapeAttr(s){
return String(s||"").replace(/&/g,"&").replace(/"/g,""").replace(//g,">");
}
/* ---------- preview render ---------- */
function render(skipItemsFormRefresh){
if(!skipItemsFormRefresh){
// keep amounts in the editable table fresh without full rebuild
document.querySelectorAll("#itemsBody tr").forEach((tr, idx) => {
const item = state.items[idx];
if(!item) return;
tr.querySelector(".amt").textContent = fmt((item.qty||0)*(item.rate||0), state.currency);
});
} else {
document.querySelectorAll("#itemsBody tr").forEach((tr, idx) => {
const item = state.items[idx];
if(!item) return;
tr.querySelector(".amt").textContent = fmt((item.qty||0)*(item.rate||0), state.currency);
});
}
$("pvFromName").textContent = state.from.name || "Your business name";
$("pvFromAddr").textContent = state.from.addr || "-";
$("pvFromEmail").textContent = state.from.email || "";
$("pvToName").textContent = state.to.name || "Client name";
$("pvToAddr").textContent = state.to.addr || "-";
$("pvToEmail").textContent = state.to.email || "";
$("pvNumber").textContent = state.number || "-";
$("pvIssue").textContent = state.issue || "-";
$("pvDue").textContent = state.due || "-";
const body = $("pvItemsBody");
body.innerHTML = "";
let subtotal = 0;
state.items.forEach(item => {
const amt = (item.qty||0) * (item.rate||0);
subtotal += amt;
const tr = document.createElement("tr");
tr.innerHTML =
""+escapeHtml(item.desc || "-")+" | " +
""+(item.qty||0)+" | " +
""+fmt(item.rate||0, state.currency)+" | " +
""+fmt(amt, state.currency)+" | ";
body.appendChild(tr);
});
const taxAmt = subtotal * ((state.taxRate||0)/100);
const total = subtotal + taxAmt;
$("pvSubtotal").textContent = fmt(subtotal, state.currency);
$("pvTax").textContent = fmt(taxAmt, state.currency);
$("pvTaxLine").style.display = state.taxRate ? "flex" : "none";
$("pvTotal").textContent = fmt(total, state.currency);
const stamp = $("stampEl");
if(state.status === "PAID"){ stamp.hidden = false; stamp.textContent = "Paid"; stamp.style.borderColor = "#3E7D4C"; stamp.style.color = "#3E7D4C"; }
else if(state.status === "SENT"){ stamp.hidden = false; stamp.textContent = "Sent"; stamp.style.borderColor = ""; stamp.style.color = ""; }
else { stamp.hidden = true; }
const notesWrap = $("pvNotesWrap");
if(state.notes && state.notes.trim()){
notesWrap.hidden = false;
$("pvNotes").textContent = state.notes;
} else { notesWrap.hidden = true; }
const isProActive = state.pro || (window.isObscuraPro && window.isObscuraPro());
$("watermark").style.display = isProActive ? "none" : "block";
$("logoBox").style.display = isProActive ? "block" : "none";
if(isProActive){
const logo = localStorage.getItem("ledgerline_logo");
if(logo){ $("pvLogo").src = logo; $("pvLogo").style.display = "block"; }
else { $("pvLogo").style.display = "none"; }
} else {
$("pvLogo").style.display = "none";
}
$("proBox").style.display = state.pro ? "none" : "block";
save();
}
/* ---------- pro unlock via redirect param ---------- */
function checkProRedirect(){
const params = new URLSearchParams(window.location.search);
if(params.get("pro") === "1"){
state.pro = true;
try{ localStorage.setItem("ledgerline_pro","1"); }catch(e){}
history.replaceState(null, "", window.location.pathname);
}
}
/* ---------- ad slot ---------- */
function initAdSlot(){
if(CONFIG.ADSENSE_CLIENT && CONFIG.ADSENSE_SLOT){
$("adSlot").innerHTML =
'';
var s = document.createElement("script");
s.async = true;
s.src = "https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=" + CONFIG.ADSENSE_CLIENT;
s.crossOrigin = "anonymous";
document.head.appendChild(s);
(window.adsbygoogle = window.adsbygoogle || []).push({});
}
}
/* ---------- init ---------- */
load();
checkProRedirect();
initTheme();
wireStatic();
hydrateInputs();
renderItemsForm();
render();
initAdSlot();
})();