Offer
Build the deal. Generate clean docs. Close faster.
`; } function htmlToText(html){ const div = document.createElement("div"); div.innerHTML = html || ""; return (div.textContent || div.innerText || "").replace(/\n\s*\n\s*\n/g, "\n\n").trim(); } function fileSafeName(name){ return String(name||"doc.html").replace(/[^\w\-\.]+/g,"_"); } function renderSanity(){ const t = calcTotals(); const issues = []; if(!state.client.name.trim()) issues.push("Client name is blank."); if(!state.offer.projectName.trim()) issues.push("Project name is blank."); if(!state.company.email.trim()) issues.push("Company email is blank."); if(!(state.items||[]).some(i => (i.name||"").trim() && Number(i.unitPrice||0) >= 0)) issues.push("No valid line items."); if(state.settings.taxEnabled && !(Number(state.settings.taxRate||0) > 0)) issues.push("Tax enabled but tax rate is 0."); if(t.totalOneTime === 0 && t.totalRecurring === 0) issues.push("Total is $0.00 — probably not intended."); const sched = computePaymentSchedule(t.totalOneTime); if(sched.warnings.length) issues.push(...sched.warnings); if(!issues.length){ return `
All good ✅
This packet is ready to send.
`; } return `
Fix ${issues.length} thing(s)
`; } function viewDocs(){ const ready = !!state.docs.lastGeneratedAt; const last = state.docs.lastGeneratedAt ? new Date(state.docs.lastGeneratedAt).toLocaleString() : "Not generated yet."; return `
Generate
${ready ? `ready` : `not ready`}
Last generated: ${esc(last)}
Download Packet HTML → open → Print to PDF for a clean signature packet.
Preview Mode
Choose doc
If preview looks good, Print/PDF for signature-ready output.
Sanity Check
Before send
${renderSanity()}
${ready ? "Generated" : "Not generated"} ${esc(state.settings.currency||"USD")}
${ready ? state.docs.combinedHTML : `

Generate docs to see your packet here.

`}
`; } function wireDocs(){ const docArea = document.getElementById("docArea"); const pick = document.getElementById("docPick"); const ensureGenerated = () => { if(!state.docs.lastGeneratedAt) generateDocs(); }; const getPickedHTML = () => { const v = pick.value; if(v==="proposal") return state.docs.proposalHTML || ""; if(v==="schedule") return state.docs.scheduleHTML || ""; if(v==="contract") return state.docs.contractHTML || ""; return state.docs.combinedHTML || ""; }; const refresh = () => { ensureGenerated(); docArea.innerHTML = getPickedHTML() || `

Nothing to show.

`; }; document.getElementById("genDocsBtn").onclick = ()=>{ generateDocs(); refresh(); }; document.getElementById("printBtn2").onclick = ()=>{ ensureGenerated(); window.print(); }; document.getElementById("refreshPreview").onclick = ()=> refresh(); pick.onchange = ()=> refresh(); document.getElementById("openNewTab").onclick = ()=> { ensureGenerated(); const html = wrapStandaloneHTML(getPickedHTML()); const w = window.open("", "_blank"); if(!w) return toast("Popup blocked."); w.document.open(); w.document.write(html); w.document.close(); }; document.getElementById("copyHTML").onclick = ()=> { ensureGenerated(); copyToClipboard(getPickedHTML()); }; document.getElementById("copyTEXT").onclick = ()=> { ensureGenerated(); copyToClipboard(htmlToText(getPickedHTML())); }; document.getElementById("copyProposal").onclick = ()=>{ ensureGenerated(); copyToClipboard(htmlToText(state.docs.proposalHTML||"")); }; document.getElementById("copyContract").onclick = ()=>{ ensureGenerated(); copyToClipboard(htmlToText(state.docs.contractHTML||"")); }; document.getElementById("copyCombined").onclick = ()=>{ ensureGenerated(); copyToClipboard(htmlToText(state.docs.combinedHTML||"")); }; document.getElementById("dlProposalHTML").onclick = ()=>{ ensureGenerated(); downloadFile(fileSafeName(`${state.client.name||"client"}-proposal.html`), wrapStandaloneHTML(state.docs.proposalHTML||""), "text/html"); toast("Downloaded ⬇️"); }; document.getElementById("dlContractHTML").onclick = ()=>{ ensureGenerated(); downloadFile(fileSafeName(`${state.client.name||"client"}-contract.html`), wrapStandaloneHTML(state.docs.contractHTML||""), "text/html"); toast("Downloaded ⬇️"); }; document.getElementById("dlCombinedHTML").onclick = ()=>{ ensureGenerated(); downloadFile(fileSafeName(`${state.client.name||"client"}-packet.html`), wrapStandaloneHTML(state.docs.combinedHTML||""), "text/html"); toast("Downloaded ⬇️"); }; refresh(); } // ========================= // Presets page // ========================= function viewPresets(){ const rows = (state.presets||[]).map(p=>`
${esc(p.name||"")}
${esc(p.id)}
`).join(""); return `
Manage Presets
Full offer snapshots
${rows || ``}
PresetActions
No presets.
Tip: name presets like “OfferType — Plan — Tier”.
Quick Load
Top bar works too
Loading overwrites current offer state (on purpose).
`; } function wirePresets(){ document.getElementById("savePresetNow").onclick = ()=> saveAsPresetFlow(); document.getElementById("newBlank").onclick = ()=>{ if(!confirm("Start a new blank offer? (Current state will be replaced)")) return; const base = defaultState(); base.presets = state.presets; state = base; save("New blank offer ✅"); active = "offer"; render(); }; document.querySelectorAll("[data-load-preset]").forEach(btn=> btn.onclick = ()=> loadPreset(btn.dataset.loadPreset)); document.querySelectorAll("[data-rename-preset]").forEach(btn=> btn.onclick = ()=> renamePreset(btn.dataset.renamePreset)); document.querySelectorAll("[data-del-preset]").forEach(btn=> btn.onclick = ()=> deletePreset(btn.dataset.delPreset)); document.getElementById("seedBtn").onclick = ()=>{ seedPresetsIfEmpty(); toast("Presets reseeded ✅"); render(); }; document.getElementById("goOffer").onclick = ()=>{ active="offer"; render(); }; } function saveAsPresetFlow(){ const name = prompt("Preset name?", `${state.offer.projectName || state.offer.title || "Offer"} — ${fmtMoney(calcTotals().totalOneTime, state.settings.currency)}`); if(!name) return; const snap = deepClone(state); snap.presets = []; snap.docs = { proposalHTML:"", contractHTML:"", scheduleHTML:"", combinedHTML:"", lastGeneratedAt:"" }; state.presets = state.presets || []; state.presets.unshift({ id: uid(), name: name.trim(), data: snap }); save("Preset saved ✅"); render(); } function loadPreset(id){ const p = (state.presets||[]).find(x=>x.id===id); if(!p?.data) return toast("Preset missing data."); if(!confirm(`Load preset "${p.name}"? (overwrites current offer)`)) return; const keepPresets = state.presets; const loaded = deepClone(p.data); loaded.presets = keepPresets; loaded.docs = { proposalHTML:"", contractHTML:"", scheduleHTML:"", combinedHTML:"", lastGeneratedAt:"" }; state = loaded; save("Preset loaded ✅"); active = "offer"; render(); } function renamePreset(id){ const p = (state.presets||[]).find(x=>x.id===id); if(!p) return; const n = prompt("New name:", p.name||""); if(!n) return; p.name = n.trim(); save("Renamed ✅"); render(); } function deletePreset(id){ const p = (state.presets||[]).find(x=>x.id===id); if(!p) return; if(!confirm(`Delete preset "${p.name}"?`)) return; state.presets = (state.presets||[]).filter(x=>x.id!==id); save("Preset deleted"); render(); } // ========================= // Backup page // ========================= function viewBackup(){ return `
Backup / Restore
Local-first
Backups include presets too.
Hard Reset
Danger
Deletes all local data for this app on this device/browser.
Backup first unless you enjoy rebuilding universes from dust.
`; } function wireBackup(){ document.getElementById("dlBackup").onclick = ()=>{ downloadFile(`offerforge-backup-${new Date().toISOString().slice(0,10)}.json`, JSON.stringify(state, null, 2), "application/json"); toast("Backup downloaded ⬇️"); }; document.getElementById("exportTextProposal").onclick = ()=>{ if(!state.docs.lastGeneratedAt) generateDocs(); downloadFile(fileSafeName(`${state.client.name||"client"}-proposal.txt`), htmlToText(state.docs.proposalHTML||""), "text/plain"); toast("Downloaded ⬇️"); }; document.getElementById("exportTextContract").onclick = ()=>{ if(!state.docs.lastGeneratedAt) generateDocs(); downloadFile(fileSafeName(`${state.client.name||"client"}-contract.txt`), htmlToText(state.docs.contractHTML||""), "text/plain"); toast("Downloaded ⬇️"); }; const f = document.getElementById("restoreFile"); f.onchange = async ()=>{ const file = f.files?.[0]; if(!file) return; const txt = await file.text(); const s = safeParse(txt, null); if(!s || typeof s !== "object") return toast("Invalid backup."); if(!confirm("Restore backup and overwrite current data?")) return; state = s; const d = defaultState(); state.settings ||= d.settings; state.company ||= d.company; state.client ||= d.client; state.offer ||= d.offer; state.items ||= []; state.payment ||= d.payment; state.contract ||= d.contract; state.docs ||= d.docs; state.presets ||= d.presets; save("Backup restored ✅"); active = "offer"; render(); f.value = ""; }; document.getElementById("wipeAll").onclick = ()=>{ if(!confirm("Wipe ALL data? (No undo)")) return; localStorage.removeItem(KEY); state = defaultState(); seedPresetsIfEmpty(); save("Wiped. Reborn. 🧼"); active = "offer"; render(); }; } // ========================= // Top bar preset select // ========================= const presetSelect = document.getElementById("presetSelect"); function refreshPresetSelect(){ const opts = (state.presets||[]); const current = presetSelect.value; presetSelect.innerHTML = `` + opts.map(p=>``).join(""); if(current && opts.some(p=>p.id===current)) presetSelect.value = current; } presetSelect.onchange = ()=>{ const id = presetSelect.value; if(!id) return; loadPreset(id); presetSelect.value = ""; }; // ========================= // Global buttons // ========================= document.getElementById("btnSaveAsPreset").onclick = ()=> saveAsPresetFlow(); document.getElementById("btnGenerate").onclick = ()=>{ generateDocs(); active = "docs"; render(); }; document.getElementById("btnPreview").onclick = ()=>{ if(!state.docs.lastGeneratedAt) generateDocs(); active = "docs"; render(); }; document.getElementById("btnPrint").onclick = ()=>{ if(!state.docs.lastGeneratedAt) generateDocs(); active = "docs"; render(); window.print(); }; document.getElementById("btnBackup").onclick = ()=>{ active = "backup"; render(); }; // ========================= // Sidebar toggle (mobile) // ========================= const toggleSidebarBtn = document.getElementById("toggleSidebarBtn"); toggleSidebarBtn.onclick = () => { document.body.classList.toggle("sideCollapsed"); }; // Hide sidebar on nav click (mobile) function collapseSidebarOnMobile(){ if(window.matchMedia("(max-width: 1060px)").matches){ document.body.classList.add("sideCollapsed"); } } navEl.addEventListener("click", (e)=>{ const btn = e.target.closest(".navItem"); if(btn) collapseSidebarOnMobile(); }); // ========================= // Reveal animations // ========================= let revealObserver = null; function hookReveal(){ if(revealObserver) revealObserver.disconnect(); revealObserver = new IntersectionObserver((entries)=>{ for(const ent of entries){ if(ent.isIntersecting){ ent.target.classList.add("reveal-in"); revealObserver.unobserve(ent.target); } } }, { threshold: 0.08 }); document.querySelectorAll("[data-reveal]").forEach(el => revealObserver.observe(el)); } // ========================= // Magnetic hover // ========================= function isTouch(){ return (("ontouchstart" in window) || (navigator.maxTouchPoints > 0)); } function hookMagnetic(){ if(isTouch()) return; // skip on touch devices const els = document.querySelectorAll("[data-magnetic]"); els.forEach(el=>{ el.onmousemove = (e)=>{ const r = el.getBoundingClientRect(); const x = (e.clientX - r.left) / r.width - 0.5; const y = (e.clientY - r.top) / r.height - 0.5; el.style.transform = `translate(${x*6}px, ${y*6}px) translateY(-2px)`; }; el.onmouseleave = ()=>{ el.style.transform = ""; }; }); } // ========================= // Cursor aura // ========================= const aura = document.getElementById("aura"); function hookAura(){ if(isTouch()) { aura.style.display = "none"; return; } let shown = false; window.addEventListener("mousemove", (e)=>{ aura.style.left = e.clientX + "px"; aura.style.top = e.clientY + "px"; if(!shown){ shown = true; aura.style.opacity = ".95"; } }, { passive:true }); window.addEventListener("mouseleave", ()=>{ aura.style.opacity = "0"; }, { passive:true }); } // ========================= // Animated starfield canvas // ========================= function starfield(){ const canvas = document.getElementById("bgCanvas"); const ctx = canvas.getContext("2d", { alpha: true }); let w=0,h=0, dpr=1; const stars = []; const STAR_COUNT = 140; function resize(){ dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); w = canvas.clientWidth = window.innerWidth; h = canvas.clientHeight = window.innerHeight; canvas.width = Math.floor(w * dpr); canvas.height = Math.floor(h * dpr); ctx.setTransform(dpr,0,0,dpr,0,0); } function makeStar(){ const layer = Math.random(); const speed = 0.08 + layer * 0.22; return { x: Math.random()*w, y: Math.random()*h, r: 0.6 + layer*1.6, vx: speed * (0.25 + Math.random()*0.75), vy: speed * (0.10 + Math.random()*0.60), a: 0.18 + layer*0.50, tw: Math.random()*Math.PI*2, tl: 0.008 + Math.random()*0.02 }; } function init(){ stars.length = 0; for(let i=0;i w+10) s.x = -10; if(s.y > h+10) s.y = -10; } requestAnimationFrame(draw); } resize(); init(); draw(); window.addEventListener("resize", ()=>{ resize(); init(); }, { passive:true }); } // ========================= // Pricing page missing functions fix (we already built pricing) // (Offer, Pricing, Payment, Contract, Docs, Presets, Backup) complete. // ========================= // ========================= // Minimal guard: if user is on an older saved state where pricing page was visited, // ensure it exists (already exists). // ========================= // ========================= // Missing: viewPricing references wirePricing etc already. // Missing: No CRM garbage; this is offer/doc engine. // ========================= // ========================= // Boot // ========================= seedPresetsIfEmpty(); starfield(); hookAura(); render(); })();