cybermax / pactsprig /app.js
CyberMax-tools's picture
Deploy pactsprig to the hub
ecfece4 verified
Raw History Blame Contribute Delete
26.3 kB
/* Pactsprig app: agreement picker, guided Cover Page form, live preview, PDF / DOCX / print, saved companies,
* presets and backup. Everything stays in this browser. © 2026 CyberMax. All rights reserved. */
(function () {
"use strict";
var C = window.PactCore, P = window.PactPDF, D = window.PactDOCX, X = window.CMXPro;
var KEY = "pactsprig:v1", FREE_PDFS = 3;
var AGREEMENTS = [
{ id: "mnda", name: "Mutual NDA", desc: "Protect what both sides share", free: true },
{ id: "psa", name: "Professional Services", desc: "Key Terms + Statement of Work", free: false },
{ id: "design-partner", name: "Design Partner", desc: "Early access for feedback", free: false },
];
var tpls = {}, state, tpl, model, renderTimer;
function $(id) { return document.getElementById(id); }
function pro() { return X.isPro(); }
function toast(m) { X.toast(m); }
function upsell(msg, from) { X.upsell(msg || "That is part of Pactsprig Pro.", from); }
function esc(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]; }); }
function agreementInfo(id) { return AGREEMENTS.filter(function (a) { return a.id === id; })[0] || AGREEMENTS[0]; }
function partiesEmpty() { return state.parties.every(function (p) { return !((p.company || "") + (p.name || "")).trim(); }); }
function allowed() { return agreementInfo(state.agreement).free || pro(); }
/* ---------- storage ---------- */
function load() {
var s = null;
try { s = JSON.parse(localStorage.getItem(KEY) || "null"); } catch (e) { s = null; }
if (!s || typeof s !== "object") s = { fresh: true };
s.agreement = agreementInfo(s.agreement).id;
s.docs = s.docs || {};
s.parties = Array.isArray(s.parties) && s.parties.length === 2 ? s.parties : [C.emptyParty(), C.emptyParty()];
s.savedParties = Array.isArray(s.savedParties) ? s.savedParties : [];
s.presets = Array.isArray(s.presets) ? s.presets : [];
s.page = s.page === "A4" ? "A4" : "Letter";
return s;
}
function save() {
try { var c = Object.assign({}, state); delete c.fresh; localStorage.setItem(KEY, JSON.stringify(c)); } catch (e) { /* private mode */ }
}
function getTpl(id) {
if (tpls[id]) return Promise.resolve(tpls[id]);
return fetch("templates/" + id + ".json").then(function (r) { if (!r.ok) throw new Error("template " + id); return r.json(); }).then(function (t) { tpls[id] = t; return t; });
}
/* ---------- picker ---------- */
function renderPicker() {
$("picker").innerHTML = AGREEMENTS.map(function (a) {
return '<button type="button" role="radio" data-agreement="' + a.id + '" aria-checked="' + (a.id === state.agreement) + '"><b>' + esc(a.name) + "</b><span>" + esc(a.desc) + "</span>" +
(a.free ? '<i class="tag free">Free</i>' : '<i class="tag pro">Pro</i>') + "</button>";
}).join("");
}
$("picker").addEventListener("click", function (e) {
var b = e.target.closest("button[data-agreement]");
if (!b) return;
switchTo(b.getAttribute("data-agreement"));
});
$("picker").addEventListener("keydown", function (e) {
if (["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp"].indexOf(e.key) < 0) return;
var i = AGREEMENTS.findIndex(function (a) { return a.id === state.agreement; }), d = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : -1;
var next = AGREEMENTS[(i + d + AGREEMENTS.length) % AGREEMENTS.length].id;
e.preventDefault();
switchTo(next).then(function () { var b = document.querySelector('[data-agreement="' + next + '"]'); if (b) b.focus(); });
});
function switchTo(id, useExample) {
return getTpl(id).then(function (t) {
state.agreement = id; tpl = t;
var doc = state.docs[id], ex = C.example(t, C.isoToday());
if (useExample === "blank") { state.docs[id] = { values: C.defaults(t, C.isoToday()) }; state.demo = false; }
else if (useExample === true || (!doc && (state.demo || state.fresh || partiesEmpty()))) {
// examples come with their own parties; keep the user's real parties if they typed some
state.docs[id] = { values: ex.values };
if (useExample === true || state.demo || state.fresh || partiesEmpty()) { state.parties = ex.parties; state.demo = true; }
} else if (!doc) state.docs[id] = { values: C.defaults(t, C.isoToday()) };
else {
// add any keys a newer template introduced
if (state.demo) state.parties = ex.parties;
var dv = C.defaults(t, C.isoToday());
Object.keys(dv).forEach(function (k) { if (!(k in doc.values)) doc.values[k] = dv[k]; });
}
if (!agreementInfo(id).free && !pro()) toast(t.name + " is part of Pro. You can fill it in and preview it; downloads need Pro.");
renderPicker(); renderParties(); renderForm(); renderPreview(); save();
}).catch(function (err) { if (window.console) console.warn(err); toast("Could not load that agreement. Check your connection and try again."); });
}
/* ---------- parties ---------- */
function renderParties() {
var roles = tpl.partyRoles || tpl.parties || ["Party 1", "Party 2"];
[0, 1].forEach(function (i) {
$("pl-" + i).textContent = roles[i];
["company", "name", "title", "address"].forEach(function (f) { var el = $("p" + i + "-" + f); if (el !== document.activeElement) el.value = state.parties[i][f] || ""; });
});
var opts = '<option value="">Saved companies…</option>' + state.savedParties.map(function (p, i) { return '<option value="' + i + '">' + esc(p.company || p.name) + "</option>"; }).join("");
Array.prototype.forEach.call(document.querySelectorAll("select[data-load-party]"), function (s) { s.innerHTML = opts; s.hidden = !state.savedParties.length; });
}
document.querySelector(".parties").addEventListener("input", function (e) {
var el = e.target, i = el.getAttribute("data-party"), f = el.getAttribute("data-field");
if (i == null || !f) return;
state.parties[+i][f] = el.value;
state.demo = false;
schedule();
});
document.querySelector(".parties").addEventListener("click", function (e) {
var b = e.target.closest("[data-save-party]");
if (!b) return;
if (!pro()) return upsell("Saved companies are part of Pro: save a company once and fill it into any agreement.", "parties");
var p = Object.assign({}, state.parties[+b.getAttribute("data-save-party")]);
if (!(p.company || "").trim()) return toast("Type the company name first.");
var at = state.savedParties.findIndex(function (q) { return (q.company || "").toLowerCase() === p.company.toLowerCase(); });
if (at > -1) state.savedParties[at] = p; else state.savedParties.push(p);
save(); renderParties(); renderPresets();
toast("Saved " + p.company + ".");
});
document.querySelector(".parties").addEventListener("change", function (e) {
var s = e.target.closest("select[data-load-party]");
if (!s || s.value === "") return;
if (!pro()) { s.value = ""; return upsell(null, "parties"); }
var p = state.savedParties[+s.value];
if (p) { state.parties[+s.getAttribute("data-load-party")] = Object.assign(C.emptyParty(), p); renderParties(); renderPreview(); save(); }
s.value = "";
});
/* ---------- cover page form ---------- */
function inputFor(key, spec, vals, label) {
var v = vals[key] == null ? "" : vals[key], id = "v-" + key.replace(/[^A-Za-z0-9]+/g, "-");
var aria = ' aria-label="' + esc(label) + '"';
if (spec.k === "sel") return '<select id="' + id + '" data-key="' + esc(key) + '"' + aria + ">" + spec.o.map(function (o) { return "<option" + (o === v ? " selected" : "") + ">" + esc(o) + "</option>"; }).join("") + "</select>";
if (spec.k === "date") return '<input type="date" id="' + id + '" data-key="' + esc(key) + '" value="' + esc(v) + '"' + aria + ">";
if (spec.k === "area") return '<textarea id="' + id + '" class="wide" rows="4" data-key="' + esc(key) + '" placeholder="' + esc(spec.ph || "") + '"' + aria + ">" + esc(v) + "</textarea>";
var num = spec.k === "num";
return '<input id="' + id + '" class="inl' + (num ? " num" : "") + '" data-key="' + esc(key) + '" value="' + esc(v) + '"' + (num ? ' inputmode="decimal"' : "") + ' placeholder="' + esc(spec.ph || "") + '" style="--w:' + (spec.w || 12) + 'ch"' + aria + ">";
}
/** Option text with its inline inputs: "Expires {years} year(s)" -> text + <input>. */
function inline(t, key, ins, vals, label) {
return String(t).replace(/\{(@?\w+)\}|([^{]+)/g, function (m, name, txt) {
if (txt) return '<span class="ot">' + esc(txt) + "</span>";
if (name.charAt(0) === "@") return '<span class="ot glob">' + esc(name === "@p1" ? "[Party 1 company]" : name === "@p2" ? "[Party 2 company]" : "[Key Terms Effective Date]") + "</span>";
return ins && ins[name] ? inputFor(key + "." + name, ins[name], vals, label + " " + name) : esc(m);
});
}
function optionsHtml(pgId, row, vals) {
var k = pgId + ":" + row.id, name = "r-" + k.replace(/[^A-Za-z0-9]+/g, "-");
var html = row.lead ? '<p class="lead">' + esc(row.lead) + "</p>" : "";
function subs(o, ok) {
if (!o.sub) return "";
return '<div class="subs">' + o.sub.map(function (s) {
var sk = ok + "." + s.id;
return '<div class="opt-row"><label class="optl"><input type="checkbox" data-key="' + esc(sk) + '"' + (vals[sk] ? " checked" : "") + "></label><div class=\"opt-body\">" + inline(s.t, sk, s.in, vals, row.label) + "</div></div>";
}).join("") + "</div>";
}
(row.options || []).forEach(function (o) {
var ok = k + "." + o.id, on = row.type === "choice" ? vals[k] === o.id : !!vals[ok];
var ctl = row.type === "choice" ? '<input type="radio" name="' + name + '" data-key="' + esc(k) + '" value="' + esc(o.id) + '"' + (on ? " checked" : "") + ">" : '<input type="checkbox" data-key="' + esc(ok) + '"' + (on ? " checked" : "") + ">";
html += '<div class="opt-row' + (on ? " on" : "") + '"><label class="optl">' + ctl + '</label><div class="opt-body">' + inline(o.t, ok, o.in, vals, row.label) + (on ? subs(o, ok) : "") + "</div></div>";
});
if (row.type === "choice" && row.optional) {
html += '<div class="opt-row' + (!vals[k] ? " on" : "") + '"><label class="optl"><input type="radio" name="' + name + '" data-key="' + esc(k) + '" value=""' + (!vals[k] ? " checked" : "") + '></label><div class="opt-body"><span class="ot muted">None (leave this out)</span></div></div>';
}
return html;
}
function rowHtml(pg, row, vals) {
if (row.type === "heading") return '<h3 class="fh">' + esc(row.label) + "</h3>";
var k = pg.id + ":" + row.id, id = "v-" + k.replace(/[^A-Za-z0-9]+/g, "-");
var free = row.type === "text" || row.type === "textarea";
var head = row.omitLabel ? "" : '<div class="fl"><label' + (free ? ' for="' + id + '"' : "") + ">" + esc(row.label) + (row.optional ? ' <span class="opt">optional</span>' : "") + "</label>" + (row.sub ? '<span class="fsub">' + esc(row.sub) + "</span>" : "") + "</div>";
var body;
if (row.type === "text" || row.type === "textarea") {
body = row.type === "text" ? '<input id="' + id + '" data-key="' + esc(k) + '" value="' + esc(vals[k]) + '" placeholder="' + esc(row.ph || "") + '">'
: '<textarea id="' + id + '" rows="3" data-key="' + esc(k) + '" placeholder="' + esc(row.ph || "") + '">' + esc(vals[k]) + "</textarea>";
} else if (row.type === "line") body = '<div class="line">' + inline(row.t, k, row.in, vals, row.label) + "</div>";
else if (row.type === "fixed") body = '<div class="line fixed">' + esc(row.t) + "</div>";
else body = '<div class="opts" role="group" aria-label="' + esc(row.label || row.lead || "") + '">' + optionsHtml(pg.id, row, vals) + "</div>";
return '<div class="frow' + (row.omitLabel ? " cont" : "") + '" data-row="' + esc(k) + '">' + head + body + (row.help ? '<p class="help">' + esc(row.help) + "</p>" : "") + "</div>";
}
function renderForm() {
var vals = state.docs[state.agreement].values;
$("cover-form").innerHTML = tpl.cover.map(function (pg, i) {
var title = pg.kicker ? (pg.kicker === "SOW" ? "Statement of Work (SOW)" : pg.kicker) : "Cover Page";
return '<details class="card cover-card" open data-page="' + esc(pg.id) + '"><summary><h2>' + esc(title) + '</h2><span class="step">' + esc(tpl.short || tpl.name) + "</span></summary>" +
(i === 0 ? '<p class="hint">' + esc(tpl.blurb || "") + "</p>" : "") + pg.rows.map(function (r) { return rowHtml(pg, r, vals); }).join("") + "</details>";
}).join("");
}
function onField(e) {
var el = e.target, key = el.getAttribute && el.getAttribute("data-key");
if (!key) return;
var vals = state.docs[state.agreement].values;
if (el.type === "checkbox") vals[key] = el.checked;
else vals[key] = el.value;
if (el.type === "checkbox" || el.type === "radio") {
// re-draw this row so nested options appear / disappear
var rowEl = el.closest(".frow"), rk = rowEl && rowEl.getAttribute("data-row");
var pgId = rk.split(":")[0], rid = rk.split(":")[1];
var pg = tpl.cover.filter(function (p) { return p.id === pgId; })[0], row = pg.rows.filter(function (r) { return r.id === rid; })[0];
var tmp = document.createElement("div");
tmp.innerHTML = rowHtml(pg, row, vals);
rowEl.replaceWith(tmp.firstChild);
var again = document.querySelector('[data-row="' + rk + '"] [data-key="' + key + '"]' + (el.type === "radio" ? '[value="' + el.value + '"]' : ""));
if (again) again.focus();
}
schedule();
}
$("cover-form").addEventListener("input", onField);
$("cover-form").addEventListener("change", function (e) { if (e.target.type === "checkbox" || e.target.type === "radio" || e.target.tagName === "SELECT") onField(e); });
/* ---------- preview ---------- */
function schedule() { clearTimeout(renderTimer); renderTimer = setTimeout(function () { renderPreview(); save(); }, 120); }
function itemHtml(it) {
var t = esc(it.t).split(esc(C.BLANK)).join('<span class="miss">' + C.BLANK + "</span>");
return '<div class="d-it' + (it.bullet ? " b" : "") + (it.lead ? " lead" : "") + '" style="--lvl:' + it.lvl + '">' + t + "</div>";
}
function runsHtml(b) {
var h = "";
if (b.l === 1 && b.h && b.r) h += "<b>" + esc(b.n + " " + b.h) + ".</b> ";
else if (b.n) h += b.l <= 2 ? "<b>" + esc(b.n) + "</b> " : esc(b.n) + " ";
if (b.h3) h += "<b>" + esc(b.h3) + "</b> ";
(b.r || []).forEach(function (r) { h += typeof r === "string" ? esc(r) : r[0] === "b" ? "<b>" + esc(r[1]) + "</b>" : '<span class="v">' + esc(r[1]) + "</span>"; });
return h;
}
function renderPreview() {
var d = state.docs[state.agreement];
model = C.build(tpl, { values: d.values, parties: state.parties });
var h = "";
model.pages.forEach(function (pg) {
h += '<section class="d-cover">' + (pg.kicker ? '<div class="d-kicker">' + esc(pg.kicker) + "</div>" : "") + '<h2 class="d-title">' + esc(pg.title) + "</h2>";
if (pg.intro) h += '<p class="d-intro">' + esc(pg.intro) + "</p>";
if (pg.lead) h += '<p class="d-lead">' + esc(pg.lead) + "</p>";
h += '<div class="d-rows">' + pg.rows.map(function (r) {
if (r.heading) return '<div class="d-head">' + esc(r.heading) + "</div>";
return '<div class="d-row"><div class="d-l"><b>' + esc(r.label) + "</b>" + (r.sub ? "<small>" + esc(r.sub) + "</small>" : "") + '</div><div class="d-r">' + r.items.map(itemHtml).join("") + "</div></div>";
}).join("") + "</div>";
if (pg.closing) h += '<p class="d-closing">' + esc(pg.closing) + "</p>";
h += '<table class="d-sign"><thead><tr><th></th>' + pg.sign.heads.map(function (x) { return "<th>" + esc(x) + "</th>"; }).join("") + "</tr></thead><tbody>" +
pg.sign.rows.map(function (r) {
return '<tr><th scope="row">' + esc(r.label) + (r.note ? "<small>" + esc(r.note) + "</small>" : "") + "</th>" + r.values.map(function (v) { return r.line ? '<td class="sigline"><span></span></td>' : "<td>" + (esc(v) || '<span class="miss">' + C.BLANK + "</span>") + "</td>"; }).join("") + "</tr>";
}).join("") + "</tbody></table>";
h += '<p class="d-att">' + esc(model.attribution) + "</p></section>";
});
h += '<section class="d-terms"><h2 class="d-tt">' + esc(model.termsTitle) + '</h2><p class="d-tn">' + esc(model.termsNote) + "</p>";
h += model.terms.map(function (b) {
if (b.l === 1 && b.h && !b.r) return '<h3 class="d-sec">' + esc(b.n + " " + b.h) + "</h3>";
return '<p class="d-p l' + b.l + '">' + runsHtml(b) + "</p>";
}).join("");
h += '<p class="d-att end"><a href="' + esc(model.termsUrl) + '" target="_blank" rel="noopener">' + esc(model.attribution.replace(/ CC BY 4\.0\.$/, "")) + '</a> <a href="' + esc(model.licenceUrl) + '" target="_blank" rel="noopener">CC BY 4.0</a>.</p>' +
'<p class="d-src">Standard Terms: ' + esc(model.termsUrl) + " · Licence: " + esc(model.licenceUrl) + "</p></section>";
h += '<p class="d-credit">' + esc(C.CREDIT) + "</p>";
if (!allowed()) h += '<div class="d-lock"><p><b>' + esc(tpl.name) + "</b> is part of Pactsprig Pro. Keep filling it in; downloads and printing unlock with Pro ($29 once).</p><button type=\"button\" class=\"btn\" id=\"btn-unlock\">Unlock all agreements</button></div>";
var docEl = $("doc");
docEl.innerHTML = h;
docEl.classList.toggle("locked", !allowed());
docEl.classList.toggle("a4", state.page === "A4");
var ul = $("btn-unlock"); if (ul) ul.onclick = function () { upsell(null, "agreement"); };
$("doc-meta").textContent = tpl.short + " · Version " + tpl.version;
var w = $("warn");
w.textContent = model.missing.length ? "Still blank: " + model.missing.slice(0, 4).join(", ") + (model.missing.length > 4 ? " and " + (model.missing.length - 4) + " more" : "") + ". Blank lines print as ________ to fill by hand." : "";
quotaLine();
}
/* ---------- quota + downloads ---------- */
function quotaLine() {
var el = $("quota");
if (pro()) { el.textContent = "Pro: unlimited downloads, Word export, no Pactsprig footer line."; el.className = "quota"; return; }
if (!agreementInfo(state.agreement).free) { el.textContent = "This agreement is part of Pro. The Mutual NDA is free."; el.className = "quota out"; return; }
var q = X.quota("pdf", FREE_PDFS);
el.textContent = q.left > 0 ? q.left + " of " + FREE_PDFS + " free PDFs left this month · printing is free" : "You have used this month's " + FREE_PDFS + " free PDFs. Printing still works, or go Pro for unlimited.";
el.className = "quota" + (q.left > 0 ? "" : " out");
}
function lockedUpsell() { upsell(tpl.name + " is part of Pactsprig Pro, with Word export and unlimited downloads.", "agreement"); }
function downloadPdf() {
if (!allowed()) return lockedUpsell();
var q = X.quota("pdf", FREE_PDFS);
if (!pro() && q.left <= 0) { quotaLine(); return upsell("You have used this month's " + FREE_PDFS + " free PDFs. Go Pro for unlimited downloads with no footer line.", "pdf-limit"); }
var bytes;
try { bytes = P.build(model, { credit: !pro(), page: state.page }); } catch (e) { toast("Sorry, the PDF could not be made: " + e.message); return; }
q.take();
X.download(C.fileName(tpl, state.parties, "pdf"), new Blob([bytes], { type: "application/pdf" }));
quotaLine();
if (!pro()) {
$("pro-cta").hidden = false;
var left = X.quota("pdf", FREE_PDFS).left;
toast("PDF downloaded. " + (left ? left + " free PDF" + (left === 1 ? "" : "s") + " left this month." : "That was your last free PDF this month."));
} else toast("PDF downloaded.");
}
function downloadDocx() {
if (!pro()) return upsell("Word (DOCX) export is part of Pro, so you can edit the Cover Page in Word or upload it to your e-signature tool.", "docx");
var bytes = D.build(model, { page: state.page });
X.download(C.fileName(tpl, state.parties, "docx"), new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }));
toast("Word file downloaded.");
}
$("btn-pdf").addEventListener("click", downloadPdf);
$("m-pdf").addEventListener("click", downloadPdf);
$("btn-docx").addEventListener("click", downloadDocx);
$("btn-print").addEventListener("click", function () {
if (!allowed()) return lockedUpsell();
window.print();
});
$("f-page").addEventListener("change", function (e) { state.page = e.target.value === "A4" ? "A4" : "Letter"; save(); renderPreview(); });
/* ---------- presets, saved companies, backup ---------- */
function renderPresets() {
var box = $("presets");
if (!pro()) {
box.innerHTML = '<p class="hint">Pro remembers the companies you work with and your usual Cover Page answers (for example “Standard NDA: Delaware, 2 years”), so the next agreement takes seconds.</p><button type="button" class="btn sm" id="btn-presets-pro">Unlock with Pro</button>';
$("btn-presets-pro").onclick = function () { upsell(null, "presets"); };
return;
}
var mine = state.presets.filter(function (p) { return p.agreement === state.agreement; });
box.innerHTML = '<h3 class="mini">Cover Page presets for ' + esc(tpl.short) + '</h3><div class="preset-add"><label class="sr" for="f-preset">Preset name</label><input id="f-preset" placeholder="e.g. Standard NDA, Delaware, 2 years"><button type="button" class="btn sm" id="btn-preset-save">Save preset</button></div>' +
'<div class="chips">' + (mine.length ? mine.map(function (p) { var i = state.presets.indexOf(p); return '<span class="chip"><button type="button" class="chip-load" data-preset="' + i + '">' + esc(p.name) + '</button><button type="button" data-del-preset="' + i + '" aria-label="Delete preset ' + esc(p.name) + '">×</button></span>'; }).join("") : '<span class="hint">No presets yet.</span>') + "</div>" +
'<h3 class="mini">Saved companies</h3><div class="chips">' + (state.savedParties.length ? state.savedParties.map(function (p, i) { return '<span class="chip"><span>' + esc(p.company) + '</span><button type="button" data-del-party="' + i + '" aria-label="Delete ' + esc(p.company) + '">×</button></span>'; }).join("") : '<span class="hint">Use “Save company” on a party to keep it here.</span>') + "</div>";
}
$("presets").addEventListener("click", function (e) {
var t = e.target;
if (t.id === "btn-preset-save") {
var name = ($("f-preset").value || "").trim() || tpl.short + " preset " + (state.presets.length + 1);
state.presets.push({ name: name, agreement: state.agreement, values: Object.assign({}, state.docs[state.agreement].values) });
save(); renderPresets(); toast("Preset saved: " + name);
} else if (t.hasAttribute("data-preset")) {
var p = state.presets[+t.getAttribute("data-preset")];
if (p) { state.docs[state.agreement].values = Object.assign(C.defaults(tpl, C.isoToday()), p.values); renderForm(); renderPreview(); save(); toast("Loaded preset: " + p.name); }
} else if (t.hasAttribute("data-del-preset")) {
state.presets.splice(+t.getAttribute("data-del-preset"), 1); save(); renderPresets();
} else if (t.hasAttribute("data-del-party")) {
state.savedParties.splice(+t.getAttribute("data-del-party"), 1); save(); renderPresets(); renderParties();
}
});
$("btn-backup").addEventListener("click", function () {
if (!pro()) return upsell("JSON backup is part of Pro.", "backup");
var c = Object.assign({}, state); delete c.fresh;
X.download("pactsprig-backup-" + C.isoToday() + ".json", JSON.stringify({ app: "pactsprig", version: 1, saved: new Date().toISOString(), data: c }, null, 1), "application/json");
});
$("f-restore").addEventListener("click", function (e) { if (!pro()) { e.preventDefault(); upsell("JSON backup and restore are part of Pro.", "backup"); } });
$("f-restore").addEventListener("change", function (e) {
var f = e.target.files && e.target.files[0];
if (!f) return;
f.text().then(function (txt) {
var j = JSON.parse(txt);
if (!j || j.app !== "pactsprig" || !j.data) throw new Error("not a Pactsprig backup");
localStorage.setItem(KEY, JSON.stringify(j.data));
state = load();
return switchTo(state.agreement).then(function () { renderPresets(); toast("Backup restored."); });
}).catch(function (err) { toast("Could not restore: " + err.message); });
e.target.value = "";
});
/* ---------- hero buttons ---------- */
$("btn-example").addEventListener("click", function () { switchTo(state.agreement, true).then(function () { toast("Example loaded. Change anything; the contract updates as you type."); }); });
$("btn-clear").addEventListener("click", function () {
state.parties = [C.emptyParty(), C.emptyParty()];
switchTo(state.agreement, "blank").then(function () { toast("Blank Cover Page. Your saved companies and presets are still here."); });
});
$("btn-cta").addEventListener("click", function () { upsell(null, "mid-cta"); });
/* ---------- Pro on/off ---------- */
function onPro(on) {
$("pro-pill").textContent = on ? "Pro ✓" : "Pro";
if (on) $("pro-cta").hidden = true;
if (tpl) { renderPreview(); renderPresets(); }
}
/* ---------- PWA ---------- */
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; });
});
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 ---------- */
state = load();
$("f-page").value = state.page;
renderPicker();
switchTo(state.agreement).then(function () {
state.fresh = false;
X.onChange(onPro);
document.documentElement.setAttribute("data-ready", "1");
});
})();