/* 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 '"; }).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 = '' + state.savedParties.map(function (p, i) { return '"; }).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 '"; 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 '
" + inline(s.t, sk, s.in, vals, row.label) + "
"; }).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 += '
' + inline(o.t, ok, o.in, vals, row.label) + (on ? subs(o, ok) : "") + "
"; }); if (row.type === "choice" && row.optional) { html += '
None (leave this out)
'; } 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 ""; }).join("") + "" + pg.sign.rows.map(function (r) { return '" + r.values.map(function (v) { return r.line ? '' : ""; }).join("") + ""; }).join("") + "
" + esc(x) + "
' + esc(r.label) + (r.note ? "" + esc(r.note) + "" : "") + "" + (esc(v) || '' + C.BLANK + "") + "
"; 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).

"; 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.

'; $("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) + '

' + '
' + (mine.length ? mine.map(function (p) { var i = state.presets.indexOf(p); return ''; }).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"); }); })();