/* 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 { "&": "&", "<": "<", ">": ">", '"': """ }[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 '' + esc(a.name) + " " + esc(a.desc) + " " +
(a.free ? 'Free ' : 'Pro ') + " ";
}).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 = 'Saved companies… ' + state.savedParties.map(function (p, i) { return '' + esc(p.company || p.name) + " "; }).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 '" + spec.o.map(function (o) { return "" + esc(o) + " "; }).join("") + " ";
if (spec.k === "date") return ' ";
if (spec.k === "area") return '";
var num = spec.k === "num";
return ' ";
}
/** Option text with its inline inputs: "Expires {years} year(s)" -> text + . */
function inline(t, key, ins, vals, label) {
return String(t).replace(/\{(@?\w+)\}|([^{]+)/g, function (m, name, txt) {
if (txt) return '' + esc(txt) + " ";
if (name.charAt(0) === "@") return '' + esc(name === "@p1" ? "[Party 1 company]" : name === "@p2" ? "[Party 2 company]" : "[Key Terms Effective Date]") + " ";
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 ? '
' + esc(row.lead) + "
" : "";
function subs(o, ok) {
if (!o.sub) return "";
return '' + o.sub.map(function (s) {
var sk = ok + "." + s.id;
return '
";
}).join("") + "
";
}
(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" ? ' " : ' ";
html += '' + ctl + ' ' + inline(o.t, ok, o.in, vals, row.label) + (on ? subs(o, ok) : "") + "
";
});
if (row.type === "choice" && row.optional) {
html += '';
}
return html;
}
function rowHtml(pg, row, vals) {
if (row.type === "heading") return '' + esc(row.label) + " ";
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 ? "" : '" + esc(row.label) + (row.optional ? ' optional ' : "") + " " + (row.sub ? '' + esc(row.sub) + " " : "") + "
";
var body;
if (row.type === "text" || row.type === "textarea") {
body = row.type === "text" ? ' '
: '";
} else if (row.type === "line") body = '' + inline(row.t, k, row.in, vals, row.label) + "
";
else if (row.type === "fixed") body = '' + esc(row.t) + "
";
else body = '' + optionsHtml(pg.id, row, vals) + "
";
return '' + head + body + (row.help ? '
' + esc(row.help) + "
" : "") + "
";
}
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 '' + esc(title) + ' ' + esc(tpl.short || tpl.name) + " " +
(i === 0 ? '' + esc(tpl.blurb || "") + "
" : "") + pg.rows.map(function (r) { return rowHtml(pg, r, vals); }).join("") + " ";
}).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('' + C.BLANK + " ");
return '' + t + "
";
}
function runsHtml(b) {
var h = "";
if (b.l === 1 && b.h && b.r) h += "" + esc(b.n + " " + b.h) + ". ";
else if (b.n) h += b.l <= 2 ? "" + esc(b.n) + " " : esc(b.n) + " ";
if (b.h3) h += "" + esc(b.h3) + " ";
(b.r || []).forEach(function (r) { h += typeof r === "string" ? esc(r) : r[0] === "b" ? "" + esc(r[1]) + " " : '' + esc(r[1]) + " "; });
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 += '' + (pg.kicker ? '' + esc(pg.kicker) + "
" : "") + '' + esc(pg.title) + " ";
if (pg.intro) h += '' + esc(pg.intro) + "
";
if (pg.lead) h += '' + esc(pg.lead) + "
";
h += '' + pg.rows.map(function (r) {
if (r.heading) return '
' + esc(r.heading) + "
";
return '
' + esc(r.label) + " " + (r.sub ? "" + esc(r.sub) + " " : "") + '
' + r.items.map(itemHtml).join("") + "
";
}).join("") + "
";
if (pg.closing) h += '' + esc(pg.closing) + "
";
h += ' ' + pg.sign.heads.map(function (x) { return "" + esc(x) + " "; }).join("") + "" +
pg.sign.rows.map(function (r) {
return '' + esc(r.label) + (r.note ? "" + esc(r.note) + " " : "") + " " + r.values.map(function (v) { return r.line ? ' ' : "" + (esc(v) || '' + C.BLANK + " ") + " "; }).join("") + " ";
}).join("") + "
";
h += '' + esc(model.attribution) + "
";
});
h += '' + esc(model.termsTitle) + ' ' + esc(model.termsNote) + "
";
h += model.terms.map(function (b) {
if (b.l === 1 && b.h && !b.r) return '' + esc(b.n + " " + b.h) + " ";
return '' + runsHtml(b) + "
";
}).join("");
h += '' + esc(model.attribution.replace(/ CC BY 4\.0\.$/, "")) + ' CC BY 4.0 .
' +
'Standard Terms: ' + esc(model.termsUrl) + " · Licence: " + esc(model.licenceUrl) + "
";
h += '' + esc(C.CREDIT) + "
";
if (!allowed()) h += '' + esc(tpl.name) + " is part of Pactsprig Pro. Keep filling it in; downloads and printing unlock with Pro ($29 once).
Unlock all agreements ";
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 = '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.
Unlock with Pro ';
$("btn-presets-pro").onclick = function () { upsell(null, "presets"); };
return;
}
var mine = state.presets.filter(function (p) { return p.agreement === state.agreement; });
box.innerHTML = 'Cover Page presets for ' + esc(tpl.short) + ' Preset name Save preset
' +
'' + (mine.length ? mine.map(function (p) { var i = state.presets.indexOf(p); return '' + esc(p.name) + ' × '; }).join("") : 'No presets yet. ') + "
" +
'Saved companies ' + (state.savedParties.length ? state.savedParties.map(function (p, i) { return '' + esc(p.company) + ' × '; }).join("") : 'Use “Save company” on a party to keep it here. ') + "
";
}
$("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");
});
})();