cybermax / rentsprig /app.js
CyberMax-tools's picture
Deploy rentsprig to the hub
a186644 verified
Raw History Blame Contribute Delete
48.6 kB
/* Rentsprig app: properties and tenants, rent receipts, rent ledger, reminder letter, move-in/move-out checklists with photos,
* live preview, PDF/print, Pro features. Everything stays in this browser. © 2026 CyberMax. All rights reserved. */
(function () {
"use strict";
var R = window.Rent, P = window.RentPDF, X = window.CMXPro, PH = window.RentPhotos;
var KEY = "rentsprig:v1";
var HUB = "https://cybermax-tools-cybermax.static.hf.space/rentsprig/index.html?utm_source=pdf-credit";
var CREDIT = "Made with Rentsprig by CyberMax · free rent receipt generator";
var $ = function (id) { return document.getElementById(id); };
var qa = function (sel, el) { return Array.prototype.slice.call((el || document).querySelectorAll(sel)); };
function pro() { return X.isPro(); }
function toast(m) { X.toast(m); }
function upsell(msg, from) { X.upsell(msg || "That is a Rentsprig Pro feature.", from); }
function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]; }); }
/* ---------- state ---------- */
function firstOfMonth() { return R.todayISO().slice(0, 8) + "01"; }
function addDays(iso, n) { var p = iso.split("-"), d = new Date(Date.UTC(+p[0], +p[1] - 1, +p[2] + n)); return d.toISOString().slice(0, 10); }
function blankReceipt() {
return { no: "", date: R.todayISO(), tenantId: "", from: "", address: "", periodFrom: firstOfMonth(), periodTo: R.monthEnd(firstOfMonth()), amount: "", method: "Bank transfer",
methodOther: "", methodRef: "", by: (S && S.landlord && S.landlord.name) || "", balance: "0", note: "", signature: "", post: true };
}
function blankLetter() {
return { tenantId: "", tenant: "", property: "", date: R.todayISO(), amount: "", dueDate: firstOfMonth(), payBy: addDays(R.todayISO(), 7), lateFee: "", howToPay: "", contact: "", landlord: "",
subject: R.LETTER_SUBJECT, body: R.LETTER_BODY };
}
function fresh() {
return { landlord: { name: "", address: "", phone: "", email: "" }, currency: guessCurrency(), page: "", logo: null, properties: [], tenants: [], receipt: null, receipts: [], counter: 0,
ledgers: {}, ledgerTenant: "", letter: null, checklists: [], clId: "", cmp: { in: "", out: "" }, tab: "receipt", results: 0 };
}
function guessCurrency() {
try {
var loc = navigator.language || "en-US", tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
if (/^en-GB/.test(loc) || /London/.test(tz)) return "GBP";
if (/^en-CA|^fr-CA/.test(loc)) return "CAD";
if (/^en-AU/.test(loc) || /^Australia/.test(tz)) return "AUD";
if (/^en-NZ/.test(loc)) return "NZD";
if (/^en-IN/.test(loc) || /Kolkata|Calcutta/.test(tz)) return "INR";
if (/Johannesburg/.test(tz)) return "ZAR";
if (/^Europe\//.test(tz)) return "EUR";
} catch (e) { /* default */ }
return "USD";
}
var S = null;
function load() {
var s = null;
try { s = JSON.parse(localStorage.getItem(KEY) || "null"); } catch (e) { s = null; }
var f = fresh();
if (!s || typeof s !== "object") s = f;
Object.keys(f).forEach(function (k) { if (s[k] == null) s[k] = f[k]; });
return s;
}
var firstVisit = false;
try { firstVisit = !localStorage.getItem(KEY); } catch (e) { firstVisit = true; }
S = load();
if (firstVisit) {
// First visit: show a finished receipt and letter straight away (example data you overwrite as you type).
var ex0 = R.example();
S.landlord = ex0.landlord; S.currency = "USD"; S.receipt = ex0.receipt; S.receipt.no = ""; S.receipt.tenantId = ""; S.letter = ex0.letter; S.letter.tenantId = "";
}
if (!S.receipt) S.receipt = blankReceipt();
if (!S.letter) S.letter = blankLetter();
var saveT = null;
function save() {
clearTimeout(saveT);
saveT = setTimeout(saveNow, 150);
}
function saveNow() {
clearTimeout(saveT);
try { localStorage.setItem(KEY, JSON.stringify(S)); } catch (e) { toast("Could not save in this browser (storage full or private mode)."); }
}
function ctx() { return { landlord: S.landlord, currency: S.currency }; }
function page() { return S.page || R.cur(S.currency).page; }
function tenant(id) { return S.tenants.filter(function (t) { return t.id === id; })[0] || null; }
function prop(id) { return S.properties.filter(function (p) { return p.id === id; })[0] || null; }
function checklist() { return S.checklists.filter(function (c) { return c.id === S.clId; })[0] || S.checklists[0] || null; }
var cmpMode = false;
/* ---------- tabs ---------- */
var docTab = S.tab === "setup" ? "receipt" : S.tab;
function switchTab(t, focus) {
S.tab = t; if (t !== "setup") docTab = t;
qa(".tabs [role=tab]").forEach(function (b) { var on = b.dataset.tab === t; b.setAttribute("aria-selected", String(on)); b.tabIndex = on ? 0 : -1; if (on && focus) b.focus(); });
qa(".panel").forEach(function (p) { p.hidden = p.id !== "panel-" + t; });
if (t === "checklist") ensureChecklist();
save(); renderPanel(); renderPreview();
}
qa(".tabs [role=tab]").forEach(function (b, i, all) {
b.addEventListener("click", function () { switchTab(b.dataset.tab); });
b.addEventListener("keydown", function (e) {
var d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (d) { e.preventDefault(); switchTab(all[(i + d + all.length) % all.length].dataset.tab, true); }
});
});
/* ---------- forms <-> model ---------- */
function model(form) {
var k = form.dataset.form;
if (k === "receipt") return S.receipt;
if (k === "letter") return S.letter;
if (k === "checklist") return checklist();
if (k === "landlord") return S.landlord;
return null;
}
function toForm(form) {
var m = model(form); if (!m) return;
qa("[name]", form).forEach(function (el) {
var v = form.dataset.form === "landlord" && (el.name === "currency" || el.name === "page") ? S[el.name] : m[el.name];
if (el.type === "checkbox") el.checked = !!v && (el.name !== "post" || pro());
else el.value = v == null ? "" : v;
});
}
function allForms() { qa("form[data-form]").forEach(toForm); syncReceiptUi(); }
qa("form[data-form]").forEach(function (form) {
form.addEventListener("submit", function (e) { e.preventDefault(); });
form.addEventListener("input", function (e) {
var el = e.target, m = model(form);
if (!el.name || !m) return;
if (form.dataset.form === "landlord" && (el.name === "currency" || el.name === "page")) S[el.name] = el.value;
else if (el.type === "checkbox") {
if (el.name === "post" && !pro()) { el.checked = false; return upsell("Posting receipts to the rent ledger is part of Pro.", "ledger-post"); }
m[el.name] = el.checked;
} else m[el.name] = el.value;
if (form.dataset.form === "receipt") syncReceiptUi();
if (form.dataset.form === "checklist" && el.name === "date") renderPicker();
changed();
});
});
function syncReceiptUi() {
qa(".r-other").forEach(function (el) { el.hidden = S.receipt.method !== "Other"; });
$("r-no-hint").textContent = pro() ? "numbered for you" : "type your own";
}
/* tenant pickers */
function tenantLabel(t) { var p = prop(t.propertyId); return t.name + (t.unit ? " · " + t.unit : "") + (p ? " · " + p.name : ""); }
function renderTenantSelects() {
qa(".tenant-select").forEach(function (sel) {
var kind = sel.dataset.fill, cur = kind === "ledger" ? S.ledgerTenant : kind === "receipt" ? S.receipt.tenantId : kind === "letter" ? S.letter.tenantId : (checklist() || {}).tenantId;
sel.innerHTML = (kind === "ledger" ? "" : '<option value="">' + (S.tenants.length ? "Choose a tenant…" : "No tenants yet (add them under Properties & tenants)") + "</option>") +
S.tenants.map(function (t) { return '<option value="' + esc(t.id) + '">' + esc(tenantLabel(t)) + "</option>"; }).join("");
if (kind === "ledger" && !S.tenants.length) sel.innerHTML = '<option value="">No tenants yet</option>';
sel.value = cur && tenant(cur) ? cur : (kind === "ledger" && S.tenants[0] ? S.tenants[0].id : "");
if (kind === "ledger") S.ledgerTenant = sel.value;
});
}
qa(".tenant-select").forEach(function (sel) {
sel.addEventListener("change", function () {
var t = tenant(sel.value), kind = sel.dataset.fill;
if (kind === "ledger") { S.ledgerTenant = sel.value; renderLedger(); return changed(); }
if (!t) return;
var p = prop(t.propertyId), addr = R.addressOf(p, t);
if (kind === "receipt") {
var r = S.receipt, from = R.isISO(r.date) ? r.date.slice(0, 8) + "01" : firstOfMonth();
r.tenantId = t.id; r.from = t.name; r.address = addr; r.amount = t.rent || r.amount; r.periodFrom = from; r.periodTo = R.monthEnd(from);
if (!r.by) r.by = S.landlord.name || "";
toForm($("panel-receipt"));
} else if (kind === "letter") {
var l = S.letter, bal = R.ledger(S.ledgers[t.id] || []).balance;
l.tenantId = t.id; l.tenant = t.name; l.property = addr; l.amount = pro() && bal > 0 ? String(bal) : t.rent || l.amount; l.dueDate = R.dueDate(R.todayISO(), t.dueDay);
if (!l.landlord) l.landlord = S.landlord.name || ""; if (!l.contact) l.contact = [S.landlord.phone, S.landlord.email].filter(Boolean).join(" or ");
toForm($("panel-letter"));
} else if (kind === "checklist") {
var c = checklist(); if (!c) return;
c.tenantId = t.id; c.tenant = t.name; c.property = [p && p.name, t.unit].filter(Boolean).join(", ");
if (!c.by) c.by = S.landlord.name || "";
toForm($("c-form")); renderPicker();
}
changed();
});
});
/* ---------- receipts ---------- */
function noteNumber(no) { var v = R.receiptValue(no); if (v != null && v > (S.counter || 0)) S.counter = v; }
function renderReceiptHistory() {
var body = $("r-hist-body");
$("btn-receipts-csv").hidden = !pro();
if (!pro()) {
body.innerHTML = '<div class="locked"><p>Pro numbers your receipts (R-0001, R-0002…), keeps every one you make, reopens any of them and exports the list as a CSV.</p><button type="button" class="btn sm" data-up="history">Unlock history</button></div>';
return;
}
if (!S.receipts.length) { body.innerHTML = '<p class="hint">Receipts you download or print appear here.</p>'; return; }
body.innerHTML = '<div class="table-wrap"><table class="ht"><thead><tr><th>No.</th><th>Date</th><th>From</th><th class="num">Amount</th><th></th></tr></thead><tbody>' +
S.receipts.map(function (r, i) {
return '<tr><td>' + esc(r.no) + "</td><td>" + esc(R.fmtDate(r.date, r.currency)) + "</td><td>" + esc(r.from) + '</td><td class="num">' + esc(R.money(R.parseNum(r.amount), r.currency)) +
'</td><td><button type="button" class="btn ghost sm" data-rh="open" data-i="' + i + '">Open</button> <button type="button" class="icon-btn" data-rh="del" data-i="' + i + '" aria-label="Delete receipt ' + esc(r.no) + '">×</button></td></tr>';
}).join("") + "</tbody></table></div>";
}
$("r-hist-body").addEventListener("click", function (e) {
var b = e.target.closest("[data-rh]"); if (!b) return;
var i = +b.dataset.i, r = S.receipts[i]; if (!r) return;
if (b.dataset.rh === "open") { S.receipt = R.clone(r); toForm($("panel-receipt")); syncReceiptUi(); changed(); $("app").scrollIntoView({ behavior: "smooth" }); }
else if (confirm("Delete receipt " + r.no + " from history?")) { S.receipts.splice(i, 1); save(); renderReceiptHistory(); }
});
$("btn-receipts-csv").addEventListener("click", function () {
if (!pro()) return upsell(null, "receipts-csv");
if (!S.receipts.length) return toast("No receipts in history yet.");
X.download("rentsprig-receipts-" + R.todayISO() + ".csv", R.receiptsCsv(S.receipts), "text/csv;charset=utf-8");
});
$("btn-new-receipt").addEventListener("click", function () {
var keep = S.receipt;
S.receipt = blankReceipt();
S.receipt.signature = keep.signature; S.receipt.method = keep.method;
if (pro()) S.receipt.no = R.receiptNo((S.counter || 0) + 1);
toForm($("panel-receipt")); syncReceiptUi(); renderTenantSelects(); changed();
toast(pro() ? "New receipt " + S.receipt.no + "." : "New receipt. Pick a tenant to fill it in.");
});
function recordReceipt() {
var r = S.receipt;
if (!r.no) r.no = R.receiptNo((S.counter || 0) + 1);
noteNumber(r.no);
r.currency = S.currency;
S.receipts = [R.clone(r)].concat(S.receipts.filter(function (x) { return x.no !== r.no; })).slice(0, 2000);
if (r.post && r.tenantId && R.parseNum(r.amount) > 0) {
var list = S.ledgers[r.tenantId] = S.ledgers[r.tenantId] || [], id = "rc-" + r.no;
var entry = { id: id, date: r.date, type: "payment", desc: "Receipt " + r.no + " · " + R.methodText(r), amount: String(R.parseNum(r.amount)) };
var at = list.map(function (e) { return e.id; }).indexOf(id);
if (at > -1) list[at] = entry; else list.push(entry);
}
toForm($("panel-receipt"));
renderReceiptHistory();
}
/* ---------- ledger ---------- */
function renderLedger() {
var tid = S.ledgerTenant, t = tenant(tid), locked = !pro();
$("l-locked").hidden = !locked;
$("panel-ledger").classList.toggle("is-locked", locked);
$("l-btns").hidden = locked || !t;
if (!t) { $("l-rows").innerHTML = '<p class="hint">Add a tenant under Properties &amp; tenants, or press Load example.</p>'; return; }
var list = S.ledgers[tid] || [], L = R.ledger(list), bal = {};
L.rows.forEach(function (r) { bal[r.id] = r.balance; });
var dis = locked ? " disabled" : "";
$("l-rows").innerHTML = list.map(function (e, i) {
var id = "le" + i;
return '<div class="lrow" data-i="' + i + '">' +
'<div><label for="' + id + '-d">Date</label><input id="' + id + '-d" type="date" data-k="date" value="' + esc(e.date) + '"' + dis + "></div>" +
'<div><label for="' + id + '-t">Type</label><select id="' + id + '-t" data-k="type"' + dis + ">" + Object.keys(R.ENTRY_TYPES).map(function (k) { return '<option value="' + k + '"' + (e.type === k ? " selected" : "") + ">" + R.ENTRY_TYPES[k].label + "</option>"; }).join("") + "</select></div>" +
'<div class="l-desc"><label for="' + id + '-x">Description</label><input id="' + id + '-x" data-k="desc" value="' + esc(e.desc) + '"' + dis + "></div>" +
'<div><label for="' + id + '-a">Amount</label><input id="' + id + '-a" data-k="amount" inputmode="decimal" value="' + esc(e.amount) + '"' + dis + "></div>" +
'<div class="l-bal">' + (e.id in bal ? "Balance " + esc(R.money(bal[e.id], S.currency)) : "") + "</div>" +
(locked ? "" : '<div class="l-tools span2"><button type="button" class="icon-btn" data-del="' + i + '" aria-label="Remove entry ' + (i + 1) + '">×</button></div>') + "</div>";
}).join("") || '<p class="hint">No entries yet. Add this month\'s rent to start.</p>';
}
$("l-rows").addEventListener("input", function (e) {
var row = e.target.closest(".lrow"); if (!row || !pro()) return;
var t = S.ledgerTenant, en = (S.ledgers[t] || [])[+row.dataset.i]; if (!en) return;
en[e.target.dataset.k] = e.target.value;
changed();
var bal = {}; R.ledger(S.ledgers[t]).rows.forEach(function (r) { bal[r.id] = r.balance; });
qa(".lrow", $("l-rows")).forEach(function (r) { var x = S.ledgers[t][+r.dataset.i]; r.querySelector(".l-bal").textContent = x && x.id in bal ? "Balance " + R.money(bal[x.id], S.currency) : ""; });
});
$("l-rows").addEventListener("click", function (e) {
var b = e.target.closest("[data-del]"); if (!b || !pro()) return;
(S.ledgers[S.ledgerTenant] || []).splice(+b.dataset.del, 1); renderLedger(); changed();
});
function addEntry(type) {
if (!pro()) return upsell("The rent ledger is part of Pro.", "ledger");
var t = tenant(S.ledgerTenant); if (!t) return toast("Add a tenant first.");
var list = S.ledgers[t.id] = S.ledgers[t.id] || [], today = R.todayISO();
var e = { id: R.uid("e"), date: today, type: type, desc: "", amount: "" };
if (type === "rent") {
e.date = R.dueDate(today, t.dueDay); e.amount = t.rent || "";
try { e.desc = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" }).format(new Date(e.date + "T00:00:00Z")) + " rent"; } catch (x) { e.desc = "Rent"; }
} else if (type === "payment") { e.desc = "Payment"; e.amount = String(Math.max(0, R.ledger(list).balance) || ""); }
else if (type === "late") e.desc = "Late fee per lease";
list.push(e); renderLedger(); changed();
var ins = qa("#l-rows input[data-k=amount]"); if (ins.length) ins[ins.length - 1].focus();
}
$("btn-l-rent").addEventListener("click", function () { addEntry("rent"); });
$("btn-l-pay").addEventListener("click", function () { addEntry("payment"); });
$("btn-l-late").addEventListener("click", function () { addEntry("late"); });
$("btn-l-row").addEventListener("click", function () { addEntry("charge"); });
$("btn-ledger-csv").addEventListener("click", function () {
if (!pro()) return upsell("CSV export of the rent ledger is part of Pro.", "ledger-csv");
var t = tenant(S.ledgerTenant); if (!t) return toast("Pick a tenant first.");
X.download("rent-ledger-" + R.fileName({ title: t.name }, "csv"), R.ledgerCsv(R.ledger(S.ledgers[t.id] || []), t), "text/csv;charset=utf-8");
result(null);
});
$("btn-ledger-pro").addEventListener("click", function () { upsell(null, "ledger"); });
/* ---------- letter ---------- */
$("btn-letter-reset").addEventListener("click", function () {
S.letter.subject = R.LETTER_SUBJECT; S.letter.body = R.LETTER_BODY; toForm($("panel-letter")); changed(); toast("Letter text reset to the template.");
});
/* ---------- checklists ---------- */
function ensureChecklist() {
if (!S.checklists.length) { var c = R.newChecklist("in", { by: S.landlord.name }); S.checklists.push(c); S.clId = c.id; save(); }
if (!checklist() || S.clId !== checklist().id) S.clId = checklist().id;
}
function clLabel(c) { return (c.kind === "out" ? "Move-out" : "Move-in") + " · " + (c.tenant || c.property || "untitled") + " · " + R.fmtDate(c.date, S.currency); }
function renderPicker() {
var c = checklist();
$("c-pick").innerHTML = S.checklists.map(function (x) { return '<option value="' + esc(x.id) + '">' + esc(clLabel(x)) + "</option>"; }).join("");
if (c) $("c-pick").value = c.id;
var ins = S.checklists.filter(function (x) { return x.kind === "in"; }), outs = S.checklists.filter(function (x) { return x.kind === "out"; });
$("c-cin").innerHTML = ins.map(function (x) { return '<option value="' + esc(x.id) + '">' + esc(clLabel(x)) + "</option>"; }).join("") || '<option value="">No move-in checklist</option>';
$("c-cout").innerHTML = outs.map(function (x) { return '<option value="' + esc(x.id) + '">' + esc(clLabel(x)) + "</option>"; }).join("") || '<option value="">No move-out checklist yet</option>';
if (S.cmp.in && ins.some(function (x) { return x.id === S.cmp.in; })) $("c-cin").value = S.cmp.in;
if (S.cmp.out && outs.some(function (x) { return x.id === S.cmp.out; })) $("c-cout").value = S.cmp.out;
var used = S.checklists.length;
$("c-quota").textContent = pro() ? "Pro: unlimited checklists, photos and comparison reports." : "Free: " + Math.min(used, 1) + " of 1 move-in checklist used, without photos. Pro adds more checklists, photos and the move-out comparison.";
$("c-quota").className = "quota" + (!pro() && used >= 1 ? " out" : "");
}
$("c-pick").addEventListener("change", function () { S.clId = this.value; cmpMode = false; toForm($("c-form")); renderTenantSelects(); renderRooms(); changed(); });
$("c-cin").addEventListener("change", function () { S.cmp.in = this.value; if (cmpMode) changed(); });
$("c-cout").addEventListener("change", function () { S.cmp.out = this.value; if (cmpMode) changed(); });
$("btn-c-newin").addEventListener("click", function () {
if (!pro() && S.checklists.length >= R.FREE.checklists) return upsell("The free plan includes 1 move-in checklist. Pro adds unlimited checklists, photos and the move-out comparison.", "checklist-limit");
var c = R.newChecklist("in", { by: S.landlord.name }); S.checklists.unshift(c); S.clId = c.id; cmpMode = false;
toForm($("c-form")); renderTenantSelects(); renderPicker(); renderRooms(); changed(); toast("New move-in checklist.");
});
$("btn-c-newout").addEventListener("click", function () {
if (!pro()) return upsell("Move-out checklists and the comparison report are part of Pro.", "move-out");
var src = checklist(); if (!src) return;
var base = src.kind === "in" ? src : S.checklists.filter(function (x) { return x.kind === "in" && x.tenantId === src.tenantId; })[0] || src;
var c = R.newChecklist("out", { tenantId: base.tenantId, tenant: base.tenant, property: base.property, by: base.by || S.landlord.name, rooms: base.rooms });
S.checklists.unshift(c); S.clId = c.id; S.cmp = { in: base.kind === "in" ? base.id : S.cmp.in, out: c.id }; cmpMode = false;
toForm($("c-form")); renderTenantSelects(); renderPicker(); renderRooms(); changed(); toast("Move-out checklist made from the move-in rooms. Record each item's condition now.");
});
$("btn-c-del").addEventListener("click", function () {
var c = checklist(); if (!c || !confirm("Delete this checklist and its photos?")) return;
c.rooms.forEach(function (r) { r.items.forEach(function (it) { (it.photos || []).forEach(function (k) { PH.del(k); }); }); });
S.checklists = S.checklists.filter(function (x) { return x.id !== c.id; });
S.clId = S.checklists[0] ? S.checklists[0].id : ""; cmpMode = false;
ensureChecklist(); toForm($("c-form")); renderTenantSelects(); renderPicker(); renderRooms(); changed();
});
$("btn-c-compare").addEventListener("click", function () {
if (!pro()) return upsell("The move-out comparison report is part of Pro.", "compare");
S.cmp.in = $("c-cin").value; S.cmp.out = $("c-cout").value;
if (!S.cmp.in || !S.cmp.out) return toast("You need a move-in and a move-out checklist to compare.");
cmpMode = true; $("btn-c-back").hidden = false; renderPreview(); toast("Comparison ready. Download it as a PDF.");
if (window.innerWidth < 980) $("preview").scrollIntoView({ behavior: "smooth", block: "start" });
});
$("btn-c-back").addEventListener("click", function () { cmpMode = false; this.hidden = true; renderPreview(); });
function renderRooms() {
var c = checklist(), box = $("c-rooms");
if (!c) { box.innerHTML = ""; return; }
box.innerHTML = c.rooms.map(function (r, ri) {
var done = r.items.filter(function (it) { return it.cond; }).length;
return '<details class="card room" open data-r="' + ri + '"><summary>' + esc(r.name) + '<span class="rc">' + done + " of " + r.items.length + "</span></summary>" +
r.items.map(function (it, ii) {
var id = "ci-" + ri + "-" + ii;
return '<div class="ci" data-i="' + ii + '"><div class="ci-top"><div class="ci-name"><label class="sr" for="' + id + '-n">Item name</label><input id="' + id + '-n" data-k="name" value="' + esc(it.name) + '"></div>' +
'<div class="conds" role="group" aria-label="Condition of ' + esc(it.name) + '">' + R.CONDITIONS.map(function (k) { return '<button type="button" data-c="' + k + '" aria-pressed="' + (it.cond === k) + '">' + k + "</button>"; }).join("") + "</div>" +
'<label class="btn ghost sm file-btn photo-btn" for="' + id + '-f" data-photo>+ Photo</label><input type="file" id="' + id + '-f" accept="image/*" multiple hidden data-file>' +
'<button type="button" class="icon-btn" data-act="del-item" aria-label="Remove ' + esc(it.name) + '">×</button></div>' +
'<div class="ci-notes"><label class="sr" for="' + id + '-x">Notes for ' + esc(it.name) + '</label><input id="' + id + '-x" data-k="notes" value="' + esc(it.notes) + '" placeholder="Notes (marks, stains, what works)"></div>' +
((it.photos || []).length ? '<div class="thumbs">' + it.photos.map(function (k, pi) { var p = PH.get(k); return '<div class="thumb">' + (p ? '<img src="' + p.data + '" alt="Photo ' + (pi + 1) + " of " + esc(it.name) + '">' : "") + '<button type="button" data-act="del-photo" data-p="' + pi + '" aria-label="Remove photo">×</button></div>'; }).join("") + "</div>" : "") +
"</div>";
}).join("") +
'<div class="room-tools"><label class="sr" for="rn-' + ri + '">Room name</label><input id="rn-' + ri + '" data-k="room" value="' + esc(r.name) + '"><button type="button" class="btn ghost sm" data-act="add-item">+ Item</button><button type="button" class="btn ghost sm danger" data-act="del-room">Remove room</button></div></details>';
}).join("");
}
function where(el) {
var rEl = el.closest("[data-r]"), iEl = el.closest(".ci"), c = checklist();
var r = rEl && c ? c.rooms[+rEl.dataset.r] : null;
return { c: c, r: r, ri: rEl ? +rEl.dataset.r : -1, it: r && iEl ? r.items[+iEl.dataset.i] : null };
}
$("c-rooms").addEventListener("input", function (e) {
var w = where(e.target), k = e.target.dataset.k; if (!w.r) return;
if (k === "room") { w.r.name = e.target.value; e.target.closest("details").querySelector("summary").firstChild.textContent = e.target.value; }
else if (w.it && (k === "name" || k === "notes")) w.it[k] = e.target.value;
changed();
});
$("c-rooms").addEventListener("click", function (e) {
var b = e.target.closest("button,[data-photo]"); if (!b) return;
var w = where(b); if (!w.r) return;
if (b.hasAttribute("data-photo")) {
if (!pro()) { e.preventDefault(); return upsell("Photos in checklists are part of Pro.", "photos"); }
return;
}
if (b.dataset.c) { w.it.cond = w.it.cond === b.dataset.c ? "" : b.dataset.c; qa("button", b.parentNode).forEach(function (x) { x.setAttribute("aria-pressed", String(x.dataset.c === w.it.cond)); }); updateCount(b, w.r); return changed(); }
var act = b.dataset.act;
if (act === "del-item") { (w.it.photos || []).forEach(function (k) { PH.del(k); }); w.r.items.splice(w.r.items.indexOf(w.it), 1); }
if (act === "del-photo") { var k = w.it.photos.splice(+b.dataset.p, 1)[0]; PH.del(k); }
if (act === "add-item") { w.r.items.push({ name: "New item", cond: "", notes: "", photos: [] }); }
if (act === "del-room") { if (!confirm("Remove " + w.r.name + " from this checklist?")) return; w.r.items.forEach(function (it) { (it.photos || []).forEach(function (k) { PH.del(k); }); }); w.c.rooms.splice(w.ri, 1); }
renderRooms(); changed();
if (act === "add-item") { var ins = qa('[data-r="' + w.ri + '"] input[data-k=name]'); if (ins.length) { ins[ins.length - 1].focus(); ins[ins.length - 1].select(); } }
});
function updateCount(el, r) { var s = el.closest("details").querySelector(".rc"); s.textContent = r.items.filter(function (it) { return it.cond; }).length + " of " + r.items.length; }
$("c-rooms").addEventListener("change", function (e) {
if (!e.target.hasAttribute("data-file")) return;
var w = where(e.target), files = Array.prototype.slice.call(e.target.files || []); e.target.value = "";
if (!w.it || !files.length) return;
if (!pro()) return upsell("Photos in checklists are part of Pro.", "photos");
Promise.all(files.map(shrink)).then(function (list) {
list.forEach(function (p) { if (!p) return; var k = R.uid("ph"); PH.put(k, p); w.it.photos = w.it.photos || []; w.it.photos.push(k); });
saveNow(); renderRooms(); changed();
toast(list.filter(Boolean).length + " photo" + (list.length > 1 ? "s" : "") + " added to " + w.it.name + ".");
});
});
$("btn-c-room").addEventListener("click", function () {
var c = checklist(); if (!c) return;
c.rooms.push({ name: "Room " + (c.rooms.length + 1), items: [{ name: "Walls & ceiling", cond: "", notes: "", photos: [] }, { name: "Floor", cond: "", notes: "", photos: [] }] });
renderRooms(); changed();
var ins = qa("#c-rooms input[data-k=room]"); ins[ins.length - 1].focus(); ins[ins.length - 1].select();
});
/** Shrink a photo on this device to at most 1280 px and re-encode it as JPEG. */
function shrink(file) {
return new Promise(function (res) {
if (!/^image\//.test(file.type || "image/")) return res(null);
var url = URL.createObjectURL(file), img = new Image();
img.onload = function () {
var s = Math.min(1, 1280 / Math.max(img.naturalWidth, img.naturalHeight)) || 1;
var w = Math.max(1, Math.round(img.naturalWidth * s)), h = Math.max(1, Math.round(img.naturalHeight * s));
var cv = document.createElement("canvas"); cv.width = w; cv.height = h;
var g = cv.getContext("2d"); g.fillStyle = "#fff"; g.fillRect(0, 0, w, h); g.drawImage(img, 0, 0, w, h);
URL.revokeObjectURL(url); res({ data: cv.toDataURL("image/jpeg", 0.74), w: w, h: h });
};
img.onerror = function () { URL.revokeObjectURL(url); toast("That file could not be read as an image."); res(null); };
img.src = url;
});
}
/* ---------- properties + tenants ---------- */
function renderSetup() {
$("s-currency").innerHTML = R.CURRENCIES.map(function (c) { return '<option value="' + c.code + '">' + c.code + " · " + esc(c.name) + "</option>"; }).join("");
toForm(qa("form[data-form=landlord]")[0]);
$("s-props").innerHTML = S.properties.length ? '<div class="list">' + S.properties.map(function (p) {
var n = S.tenants.filter(function (t) { return t.propertyId === p.id; }).length;
return '<div class="li"><div><b>' + esc(p.name) + "</b><small>" + esc(p.address) + " · " + n + " tenant" + (n === 1 ? "" : "s") + '</small></div><button type="button" class="icon-btn" data-delprop="' + esc(p.id) + '" aria-label="Delete ' + esc(p.name) + '">×</button></div>';
}).join("") + "</div>" : '<p class="hint">No properties yet.</p>';
$("s-tenants").innerHTML = S.tenants.length ? '<div class="list">' + S.tenants.map(function (t) {
return '<div class="li"><div><b>' + esc(tenantLabel(t)) + "</b><small>" + (t.rent ? esc(R.money(R.parseNum(t.rent), S.currency)) + " due day " + esc(t.dueDay || "1") : "") + (t.email ? " · " + esc(t.email) : "") +
'</small></div><button type="button" class="btn ghost sm" data-edtenant="' + esc(t.id) + '">Edit</button><button type="button" class="icon-btn" data-deltenant="' + esc(t.id) + '" aria-label="Delete ' + esc(t.name) + '">×</button></div>';
}).join("") + "</div>" : '<p class="hint">No tenants yet.</p>';
$("n-prop").innerHTML = S.properties.map(function (p) { return '<option value="' + esc(p.id) + '">' + esc(p.name) + "</option>"; }).join("") || '<option value="">Add a property first</option>';
var q = $("s-quota");
q.textContent = pro() ? "Pro: unlimited properties and tenants." : "Free plan: " + Math.min(S.properties.length, 1) + " of " + R.FREE.properties + " property and " + Math.min(S.tenants.length, R.FREE.tenants) + " of " + R.FREE.tenants + " tenants used. Pro makes them unlimited.";
q.className = "quota" + (!pro() && (S.properties.length >= R.FREE.properties || S.tenants.length >= R.FREE.tenants) ? " out" : "");
}
$("f-prop").addEventListener("submit", function (e) {
e.preventDefault();
var name = $("p-name").value.trim(), address = $("p-address").value.trim();
if (!pro() && S.properties.length >= R.FREE.properties) return upsell("The free plan holds 1 property. Pro adds unlimited properties and tenants.", "property-limit");
if (!name) { $("p-name").focus(); return toast("Give the property a name, e.g. its street address."); }
S.properties.push({ id: R.uid("p"), name: name, address: address || name });
$("p-name").value = ""; $("p-address").value = "";
saveNow(); renderAll(); toast("Added " + name + ".");
});
var editing = "";
$("f-tenant").addEventListener("submit", function (e) {
e.preventDefault();
var f = this, v = {};
qa("[name]", f).forEach(function (el) { v[el.name] = el.value.trim(); });
if (!v.name) { $("n-name").focus(); return toast("Add the tenant's name."); }
if (!S.properties.length) return toast("Add a property first.");
if (!editing && !pro() && S.tenants.length >= R.FREE.tenants) return upsell("The free plan holds 2 tenants. Pro adds unlimited properties and tenants.", "tenant-limit");
if (editing) { var t = tenant(editing); if (t) Object.keys(v).forEach(function (k) { t[k] = v[k]; }); }
else S.tenants.push(Object.assign({ id: R.uid("t") }, v));
editing = ""; f.reset(); $("btn-add-tenant").textContent = "Add tenant"; $("btn-tenant-cancel").hidden = true;
saveNow(); renderAll(); toast("Saved " + v.name + ".");
});
$("btn-tenant-cancel").addEventListener("click", function () { editing = ""; $("f-tenant").reset(); this.hidden = true; $("btn-add-tenant").textContent = "Add tenant"; });
$("panel-setup").addEventListener("click", function (e) {
var b = e.target.closest("[data-delprop],[data-deltenant],[data-edtenant]"); if (!b) return;
if (b.dataset.delprop) {
var p = prop(b.dataset.delprop), n = S.tenants.filter(function (t) { return t.propertyId === p.id; });
if (!confirm("Delete " + p.name + (n.length ? " and its " + n.length + " tenant(s)" : "") + "?")) return;
S.properties = S.properties.filter(function (x) { return x.id !== p.id; });
S.tenants = S.tenants.filter(function (t) { return t.propertyId !== p.id; });
} else if (b.dataset.deltenant) {
var t = tenant(b.dataset.deltenant);
if (!confirm("Delete " + t.name + "? Their ledger is deleted too.")) return;
S.tenants = S.tenants.filter(function (x) { return x.id !== t.id; }); delete S.ledgers[t.id];
} else {
var te = tenant(b.dataset.edtenant); editing = te.id;
qa("#f-tenant [name]").forEach(function (el) { el.value = te[el.name] || ""; });
$("btn-add-tenant").textContent = "Save tenant"; $("btn-tenant-cancel").hidden = false; $("n-name").focus();
return;
}
saveNow(); renderAll();
});
/* logo */
$("f-logo").addEventListener("click", function (e) { if (!pro()) { e.preventDefault(); upsell("Your logo on every document is part of Pro.", "logo"); } });
$("f-logo").addEventListener("change", function () {
var f = this.files && this.files[0]; this.value = "";
if (!f) return;
if (f.size > 8e6) return toast("That image is over 8 MB. Try a smaller logo.");
shrink(f).then(function (p) {
if (!p) return;
var s = Math.min(1, 600 / p.w, 240 / p.h);
if (s < 1) {
var img = new Image(); img.onload = function () {
var cv = document.createElement("canvas"); cv.width = Math.round(p.w * s); cv.height = Math.round(p.h * s);
cv.getContext("2d").drawImage(img, 0, 0, cv.width, cv.height);
S.logo = { data: cv.toDataURL("image/jpeg", 0.9), w: cv.width, h: cv.height }; saveNow(); logoUi(); renderPreview(); toast("Logo added.");
}; img.src = p.data;
} else { S.logo = p; saveNow(); logoUi(); renderPreview(); toast("Logo added."); }
});
});
$("btn-logo-clear").addEventListener("click", function () { S.logo = null; saveNow(); logoUi(); renderPreview(); });
function logoUi() {
var has = pro() && S.logo && S.logo.data;
$("logo-prev").innerHTML = has ? '<img src="' + S.logo.data + '" alt="Your logo">' : "No logo";
$("btn-logo-clear").hidden = !has;
}
/* ---------- preview ---------- */
function currentSpec() {
var c = ctx();
if (docTab === "ledger") { var t = tenant(S.ledgerTenant); return { kind: "ledger", spec: R.ledgerSpec(t, t && prop(t.propertyId), t ? S.ledgers[t.id] || [] : [], c) }; }
if (docTab === "letter") return { kind: "letter", spec: R.letterSpec(S.letter, c) };
if (docTab === "checklist") {
var cl = checklist();
if (cmpMode) {
var a = S.checklists.filter(function (x) { return x.id === S.cmp.in; })[0], b = S.checklists.filter(function (x) { return x.id === S.cmp.out; })[0];
if (a && b) return { kind: "compare", spec: R.compareSpec(a, b, c) };
}
return { kind: "checklist", spec: R.checklistSpec(cl || R.newChecklist("in"), c) };
}
return { kind: "receipt", spec: R.receiptSpec(S.receipt, c) };
}
function blockHtml(b) {
switch (b.t) {
case "head":
var logo = b.logo && pro() && S.logo && S.logo.data ? '<img class="d-logo" src="' + S.logo.data + '" alt="">' : "", f = b.from || {};
return '<header class="d-head"><div class="d-from">' + logo + (f.name ? '<div class="d-name">' + esc(f.name) + "</div>" : "") + '<div class="d-lines">' + (f.lines || []).map(esc).join("\n") + "</div></div>" +
'<div class="d-meta">' + (b.title ? '<div class="d-title' + (b.small ? " small" : "") + '">' + esc(b.title) + "</div>" : "") + "<table>" +
(b.meta || []).filter(function (m) { return m[1]; }).map(function (m) { return "<tr><td>" + esc(m[0]) + "</td><td>" + esc(m[1]) + "</td></tr>"; }).join("") + "</table></div></header>";
case "kv":
return '<div class="d-kv">' + b.rows.map(function (r) { return "<div><span>" + esc(r[0]) + "</span><p" + (r[1] ? "" : ' class="empty"') + ">" + esc(r[1] || "-") + "</p></div>"; }).join("") + "</div>";
case "amount":
return '<div class="d-amount"><div><span>' + esc(b.label) + "</span>" + (b.sub ? "<p>" + esc(b.sub) + "</p>" : "") + "</div><div><strong>" + esc(b.value) + "</strong>" + (b.status ? '<em class="' + (b.ok ? "ok" : "bad") + '">' + esc(b.status) + "</em>" : "") + "</div></div>";
case "h": return b.plain ? '<h3 class="d-subj">' + esc(b.text) + "</h3>" : '<h3 class="d-h">' + esc(b.text) + "</h3>";
case "p": return '<p class="d-p' + (b.small ? " small" : "") + '">' + esc(b.text) + "</p>";
case "space": return '<div style="height:' + Math.round((b.h || 10) * 1.33) + 'px"></div>';
case "table":
var cell = function (c, i, tag) { var o = c && typeof c === "object" ? c : { v: c }; return "<" + tag + (b.cols[i].a === "r" ? ' class="r"' : "") + (o.color || o.bold ? ' style="' + (o.color ? "color:" + o.color + ";" : "") + (o.bold ? "font-weight:700" : "") + '"' : "") + ">" + esc(o.v) + "</" + tag + ">"; };
return '<table class="d-t"><thead><tr>' + b.cols.map(function (c) { return "<th" + (c.a === "r" ? ' class="r"' : "") + ' style="width:' + Math.round(c.w * 100) + '%">' + esc(c.t) + "</th>"; }).join("") + "</tr></thead><tbody>" +
b.rows.map(function (r) { return "<tr>" + r.map(function (c, i) { return cell(c, i, "td"); }).join("") + "</tr>"; }).join("") + "</tbody>" +
(b.foot ? "<tfoot>" + b.foot.map(function (r) { return "<tr>" + r.map(function (c, i) { return cell(c, i, "td"); }).join("") + "</tr>"; }).join("") + "</tfoot>" : "") + "</table>";
case "photos":
return '<div class="d-photos">' + b.items.map(function (it) { var p = it.key && PH.get(it.key); return '<figure><div class="ph">' + (p ? '<img src="' + p.data + '" alt="">' : esc(it.empty || "No photo")) + "</div><figcaption>" + esc(it.caption) + "</figcaption></figure>"; }).join("") + "</div>";
case "sign":
return '<div class="d-sign">' + b.items.map(function (it) { return "<div><b>" + esc(it.name || "") + "</b><i>" + esc(it.date || "") + "</i><span>" + esc(it.label) + "</span><span>Date</span></div>"; }).join("") + "</div>";
}
return "";
}
var lastSpec = null;
function renderPreview() {
var cur = currentSpec(); lastSpec = cur;
var el = $("doc");
el.className = "paper" + (page() === "A4" ? " a4" : "");
el.innerHTML = cur.spec.blocks.map(blockHtml).join("") + (pro() ? "" : '<footer class="d-credit">' + esc(CREDIT) + "</footer>");
$("btn-pdf").textContent = cur.kind === "compare" ? "Download comparison PDF" : cur.kind === "ledger" ? "Download statement PDF" : "Download PDF";
quotaLine(cur.kind);
fit();
}
function fit() {
var wrap = $("paper-wrap"), el = $("doc");
var s = Math.min(1, wrap.clientWidth / el.offsetWidth);
el.style.transform = "scale(" + s + ")";
wrap.style.height = Math.ceil(el.offsetHeight * s) + "px";
}
window.addEventListener("resize", fit);
function quotaLine(kind) {
var el = $("quota"), t, out = false;
if (pro()) t = { receipt: "Pro: no footer; the receipt is numbered and saved to history.", ledger: "Pro: statement PDF and CSV export.", letter: "Pro: no footer line.", checklist: "Pro: photos embedded, no footer.", compare: "Pro: comparison report with photos side by side." }[kind];
else {
t = { receipt: "Free: unlimited receipts with a small Rentsprig footer. Pro removes it and numbers receipts for you.", ledger: "The rent ledger PDF and CSV are part of Pro.", letter: "Free: unlimited letters with a small Rentsprig footer.",
checklist: "Free: " + Math.min(S.checklists.length, 1) + " of 1 move-in checklist used, without photos.", compare: "The comparison report is part of Pro." }[kind];
out = kind === "ledger" || kind === "compare";
}
el.textContent = t; el.className = "quota" + (out ? " out" : "");
}
var changeT = null;
function changed() { save(); clearTimeout(changeT); changeT = setTimeout(renderPreview, 60); }
/* ---------- PDF + print ---------- */
function bytes(dataUrl) {
var b = atob(String(dataUrl).split(",")[1] || ""), u = new Uint8Array(b.length);
for (var i = 0; i < b.length; i++) u[i] = b.charCodeAt(i);
return u;
}
function gate(kind) {
if (pro()) return true;
if (kind === "ledger") { upsell("The rent ledger statement and CSV are part of Pro.", "ledger"); return false; }
if (kind === "compare") { upsell("The move-out comparison report is part of Pro.", "compare"); return false; }
return true;
}
function pdfBytes(cur) {
cur = cur || currentSpec();
var imgs = {};
if (pro()) R.specPhotos(cur.spec).forEach(function (k) { var p = PH.get(k); if (p) imgs[k] = { bytes: bytes(p.data), w: p.w, h: p.h }; });
var logo = pro() && S.logo && S.logo.data ? { bytes: bytes(S.logo.data), w: S.logo.w, h: S.logo.h } : null;
return P.build(cur.spec, { page: page(), credit: !pro(), creditText: CREDIT, creditUrl: HUB, logo: logo, images: imgs, author: S.landlord.name || "" });
}
$("btn-pdf").addEventListener("click", function () {
var cur = currentSpec();
if (!gate(cur.kind)) return;
if (cur.kind === "receipt" && pro()) { recordReceipt(); cur = currentSpec(); }
var out;
try { out = pdfBytes(cur); } catch (e) { return toast("Sorry, the PDF could not be made: " + e.message); }
X.download(R.fileName(cur.spec), new Blob([out], { type: "application/pdf" }));
result(cur.kind === "receipt" && pro() ? "PDF downloaded. Receipt " + S.receipt.no + " saved to history." : "PDF downloaded.");
});
$("m-pdf").addEventListener("click", function () { $("btn-pdf").click(); });
$("btn-print").addEventListener("click", function () {
var cur = currentSpec();
if (!gate(cur.kind)) return;
if (cur.kind === "receipt" && pro()) recordReceipt();
renderPreview(); result(null);
setTimeout(function () { window.print(); }, 30);
});
function result(msg) {
S.results = (S.results || 0) + 1; saveNow();
if (!pro()) $("pro-cta").hidden = false;
if (msg) toast(msg);
}
/* ---------- example, backup ---------- */
function hasOwnData() { return S.properties.some(function (p) { return p.id !== "p-alder"; }) || S.tenants.some(function (t) { return !/^t-(ellis|nair)$/.test(t.id); }); }
$("btn-example").addEventListener("click", function () {
if (hasOwnData() && !confirm("Load the example? It replaces your properties, tenants and documents in this browser.")) return;
var ex = R.example();
S.landlord = ex.landlord; S.properties = ex.properties; S.tenants = ex.tenants; S.ledgers = ex.ledgers; S.letter = ex.letter; S.receipt = ex.receipt; S.receipt.post = true;
S.currency = "USD";
S.checklists = pro() ? ex.checklists : [ex.checklists[0]];
S.clId = ex.checklists[0].id; S.cmp = { in: ex.checklists[0].id, out: pro() ? ex.checklists[1].id : "" };
S.ledgerTenant = ex.tenants[0].id; cmpMode = false;
if (pro()) noteNumber(S.receipt.no);
saveNow(); renderAll();
if (S.tab === "setup") switchTab("receipt");
toast("Example loaded: a duplex with two tenants.");
if (window.innerWidth < 980) $("preview").scrollIntoView({ behavior: "smooth", block: "start" });
});
$("btn-backup").addEventListener("click", function () {
if (!pro()) return upsell("JSON backup of all your data is part of Pro.", "backup");
saveNow();
X.download("rentsprig-backup-" + R.todayISO() + ".json", JSON.stringify({ app: "rentsprig", version: 1, saved: new Date().toISOString(), data: S, photos: PH.all() }), "application/json");
});
$("f-restore").addEventListener("change", function () {
var f = this.files && this.files[0]; this.value = "";
if (!f) return;
f.text().then(function (t) {
var j = JSON.parse(t), d = j && j.app === "rentsprig" ? j.data : null;
if (!d || typeof d !== "object" || !Array.isArray(d.tenants)) throw new Error("not a Rentsprig backup");
var f0 = fresh(); Object.keys(f0).forEach(function (k) { if (d[k] == null) d[k] = f0[k]; });
S = d; if (!S.receipt) S.receipt = blankReceipt(); if (!S.letter) S.letter = blankLetter();
return Promise.all(Object.keys(j.photos || {}).map(function (k) { return PH.put(k, j.photos[k]); }));
}).then(function () { saveNow(); renderAll(); toast("Backup restored."); })
.catch(function (e) { toast("Could not restore: " + e.message + "."); });
});
$("btn-wipe").addEventListener("click", function () {
if (!confirm("Clear all Rentsprig data and photos in this browser? This cannot be undone.")) return;
S = fresh(); S.receipt = blankReceipt(); S.letter = blankLetter(); PH.clear(); saveNow(); renderAll(); toast("All data cleared.");
});
document.addEventListener("click", function (e) { var b = e.target.closest("[data-up]"); if (b) upsell(null, b.dataset.up); });
$("btn-cta").addEventListener("click", function () { upsell(null, "mid-cta"); });
/* ---------- render everything ---------- */
function renderPanel() {
if (S.tab === "ledger") renderLedger();
if (S.tab === "checklist") { renderPicker(); renderRooms(); }
if (S.tab === "setup") renderSetup();
}
function renderAll() {
$("r-method").innerHTML = R.METHODS.map(function (m) { return "<option>" + m + "</option>"; }).join("");
if (S.tab === "checklist") ensureChecklist();
renderTenantSelects(); allForms(); renderReceiptHistory(); renderLedger(); renderSetup(); logoUi();
if (S.checklists.length) { renderPicker(); renderRooms(); }
renderPreview();
}
/* ---------- Pro state ---------- */
function onPro(isPro) {
$("pro-pill").textContent = isPro ? "Pro ✓" : "Go Pro";
if (isPro) { $("pro-cta").hidden = true; if (!S.receipt.no) S.receipt.no = R.receiptNo((S.counter || 0) + 1); }
renderAll();
}
/* ---------- install + offline ---------- */
var deferred = null;
window.addEventListener("beforeinstallprompt", function (e) { e.preventDefault(); deferred = e; $("btn-install").hidden = false; });
$("btn-install").addEventListener("click", function () {
if (!deferred) return;
deferred.prompt(); deferred.userChoice.finally(function () { deferred = null; $("btn-install").hidden = true; });
});
window.addEventListener("appinstalled", function () { $("btn-install").hidden = true; });
if ("serviceWorker" in navigator && (location.protocol === "https:" || /^(localhost|127\.0\.0\.1)$/.test(location.hostname))) {
window.addEventListener("load", function () { navigator.serviceWorker.register("sw.js").catch(function () { /* offline cache is optional */ }); });
}
/* ---------- start ---------- */
switchTabSilently(S.tab);
function switchTabSilently(t) {
qa(".tabs [role=tab]").forEach(function (b) { var on = b.dataset.tab === t; b.setAttribute("aria-selected", String(on)); b.tabIndex = on ? 0 : -1; });
qa(".panel").forEach(function (p) { p.hidden = p.id !== "panel-" + t; });
}
PH.loadAll().then(function () {
X.onChange(onPro);
window.Rentsprig = { state: function () { return S; }, pdf: function () { return pdfBytes(); }, ready: true };
});
})();