/* 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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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" ? "" : '") + S.tenants.map(function (t) { return '"; }).join(""); if (kind === "ledger" && !S.tenants.length) sel.innerHTML = ''; 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 = '

Pro numbers your receipts (R-0001, R-0002…), keeps every one you make, reopens any of them and exports the list as a CSV.

'; return; } if (!S.receipts.length) { body.innerHTML = '

Receipts you download or print appear here.

'; return; } body.innerHTML = '
' + S.receipts.map(function (r, i) { return ''; }).join("") + "
No.DateFromAmount
' + esc(r.no) + "" + esc(R.fmtDate(r.date, r.currency)) + "" + esc(r.from) + '' + esc(R.money(R.parseNum(r.amount), r.currency)) + '
"; } $("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 = '

Add a tenant under Properties & tenants, or press Load example.

'; 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 '
' + '
" + '
" + '
" + '
" + '
' + (e.id in bal ? "Balance " + esc(R.money(bal[e.id], S.currency)) : "") + "
" + (locked ? "" : '
') + "
"; }).join("") || '

No entries yet. Add this month\'s rent to start.

'; } $("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 '"; }).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 '"; }).join("") || ''; $("c-cout").innerHTML = outs.map(function (x) { return '"; }).join("") || ''; 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 '
' + esc(r.name) + '' + done + " of " + r.items.length + "" + r.items.map(function (it, ii) { var id = "ci-" + ri + "-" + ii; return '
' + '
' + R.CONDITIONS.map(function (k) { return '"; }).join("") + "
" + '' + '
' + '
' + ((it.photos || []).length ? '
' + it.photos.map(function (k, pi) { var p = PH.get(k); return '
' + (p ? 'Photo ' + (pi + 1) + ' : "") + '
'; }).join("") + "
" : "") + "
"; }).join("") + '
'; }).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 '"; }).join(""); toForm(qa("form[data-form=landlord]")[0]); $("s-props").innerHTML = S.properties.length ? '
' + S.properties.map(function (p) { var n = S.tenants.filter(function (t) { return t.propertyId === p.id; }).length; return '
' + esc(p.name) + "" + esc(p.address) + " · " + n + " tenant" + (n === 1 ? "" : "s") + '
'; }).join("") + "
" : '

No properties yet.

'; $("s-tenants").innerHTML = S.tenants.length ? '
' + S.tenants.map(function (t) { return '
' + esc(tenantLabel(t)) + "" + (t.rent ? esc(R.money(R.parseNum(t.rent), S.currency)) + " due day " + esc(t.dueDay || "1") : "") + (t.email ? " · " + esc(t.email) : "") + '
'; }).join("") + "
" : '

No tenants yet.

'; $("n-prop").innerHTML = S.properties.map(function (p) { return '"; }).join("") || ''; 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 ? '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 ? '' : "", f = b.from || {}; return '
' + logo + (f.name ? '
' + esc(f.name) + "
" : "") + '
' + (f.lines || []).map(esc).join("\n") + "
" + '
' + (b.title ? '
' + esc(b.title) + "
" : "") + "" + (b.meta || []).filter(function (m) { return m[1]; }).map(function (m) { return ""; }).join("") + "
" + esc(m[0]) + "" + esc(m[1]) + "
"; case "kv": return '
' + b.rows.map(function (r) { return "
" + esc(r[0]) + "" + esc(r[1] || "-") + "

"; }).join("") + "
"; case "amount": return '
' + esc(b.label) + "" + (b.sub ? "

" + esc(b.sub) + "

" : "") + "
" + esc(b.value) + "" + (b.status ? '' + esc(b.status) + "" : "") + "
"; case "h": return b.plain ? '

' + esc(b.text) + "

" : '

' + esc(b.text) + "

"; case "p": return '

' + esc(b.text) + "

"; case "space": return '
'; 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) + ""; }; return '' + b.cols.map(function (c) { return "' + esc(c.t) + ""; }).join("") + "" + b.rows.map(function (r) { return "" + r.map(function (c, i) { return cell(c, i, "td"); }).join("") + ""; }).join("") + "" + (b.foot ? "" + b.foot.map(function (r) { return "" + r.map(function (c, i) { return cell(c, i, "td"); }).join("") + ""; }).join("") + "" : "") + "
"; case "photos": return '
' + b.items.map(function (it) { var p = it.key && PH.get(it.key); return '
' + (p ? '' : esc(it.empty || "No photo")) + "
" + esc(it.caption) + "
"; }).join("") + "
"; case "sign": return '
' + b.items.map(function (it) { return "
" + esc(it.name || "") + "" + esc(it.date || "") + "" + esc(it.label) + "Date
"; }).join("") + "
"; } 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() ? "" : '"); $("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 ""; }).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 }; }); })();