Files
svsmspcalc/SVS-MSP-Calculator.js

1604 lines
79 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── PRICING VARIABLES ───────────────────────────────────────────
// All rates in CAD. Loaded from package-prices.csv at startup.
// Falls back to built-in defaults if CSV is unavailable.
// ADMIN_FEE_FLOOR = minimum site admin fee regardless of seat count
// ADMIN_FEE_MINIMUM = engagement threshold; admin = max(FLOOR, MIN-subtotal)
// ADMIN_FEE_ZT = supplement added to admin when ZT is active
// TOOL_COST_* = internal cost estimates (VS comparison only, not billed)
// IT_SALARY_1/5 = Ottawa benchmark salaries for VS comparison
// ─────────────────────────────────────────────────────────────────
let RATE_M365 = 130;
let RATE_BYOL = 110;
let ADDON_EXT_HOURS = 25;
let ADDON_1PASSWORD = 9;
let ADDON_INKY = 5;
let ADDON_ZERO_TRUST_USER= 55;
let RATE_ENDPOINT = 35;
let RATE_SERVER = 120;
let ADDON_USB_BLOCKING = 4;
let ADDON_BARE_METAL_BACKUP = 25;
let ZT_SEAT_RATE = 25;
let ZT_ROUTER_RATE = 100;
let ADMIN_FEE_FLOOR = 150;
let ADMIN_FEE_MINIMUM = 650;
let ADMIN_FEE_ZT = 250;
let ADMIN_1PWM_PCT = 0.10;
let VOIP_RATE_BASIC = 28;
let VOIP_RATE_STANDARD = 35;
let VOIP_RATE_PREMIUM = 45;
let VOIP_PHONE_RATE = 15;
let VOIP_FAX_RATE = 10;
let TOOL_COST_PER_USER = 42;
let TOOL_COST_PER_ENDPOINT = 23;
let TOOL_COST_MIN = 650;
let IT_SALARY_1 = 85000;
let IT_SALARY_5 = 420000;
// CONTRACT_DISCOUNT: discount applied to MRR based on contract length
// m2m = month-to-month (no discount), 12mo = 3% off, 24mo = 5% off
let DISCOUNT_M2M = 0;
let DISCOUNT_12MO = 0.03;
let DISCOUNT_24MO = 0.05;
let HST_RATE = 0.13; // Ontario HST 13%
// ── Nudge state — module-scoped (not window properties) ──────────
let _nudges = [];
let _nudgeIndex = 0;
let _nudgeTimer;
let _pricingFallbackShown = false;
function showPricingStatus(message) {
const host = document.querySelector('.top-bar-right');
if (!host) return;
let el = document.getElementById('pricingStatus');
if (!el) {
el = document.createElement('div');
el.id = 'pricingStatus';
el.style.marginTop = '6px';
el.style.fontSize = '11px';
el.style.letterSpacing = '0.02em';
el.style.color = 'var(--amber)';
host.appendChild(el);
}
el.textContent = message;
}
function reportPricingFallback(reason) {
if (_pricingFallbackShown) return;
_pricingFallbackShown = true;
console.warn(`[SVS Quote] ${reason} Using built-in pricing defaults.`);
showPricingStatus('Pricing CSV unavailable - using built-in defaults');
}
// ── loadPricing() ────────────────────────────────────────────────
// Fetches package-prices.csv and overrides the pricing variables above.
// Falls back to built-in defaults with a visible warning if CSV fails.
async function loadPricing() {
let appliedKeys = 0;
try {
const res = await fetch('package-prices.csv', { cache: 'no-store' });
if (!res.ok) {
reportPricingFallback(`Could not load package-prices.csv (HTTP ${res.status}).`);
return false;
}
const text = await res.text();
const lines = text.split('\n').slice(1); // skip header row
lines.forEach(line => {
const parts = line.split(',');
if (parts.length < 3) return;
const key = parts[1].trim();
const val = parseFloat(parts[2].trim());
if (isNaN(val)) return;
let matched = true;
switch (key) {
case 'RATE_M365': RATE_M365 = val; break;
case 'RATE_BYOL': RATE_BYOL = val; break;
case 'ADDON_EXT_HOURS': ADDON_EXT_HOURS = val; break;
case 'ADDON_1PASSWORD': ADDON_1PASSWORD = val; break;
case 'ADDON_INKY': ADDON_INKY = val; break;
case 'ADDON_ZERO_TRUST_USER': ADDON_ZERO_TRUST_USER = val; break;
case 'RATE_ENDPOINT': RATE_ENDPOINT = val; break;
case 'RATE_SERVER': RATE_SERVER = val; break;
case 'ADDON_USB_BLOCKING': ADDON_USB_BLOCKING = val; break;
case 'ADDON_BARE_METAL_BACKUP': ADDON_BARE_METAL_BACKUP = val; break;
case 'ZT_SEAT_RATE': ZT_SEAT_RATE = val; break;
case 'ZT_ROUTER_RATE': ZT_ROUTER_RATE = val; break;
case 'ADMIN_FEE_FLOOR': ADMIN_FEE_FLOOR = val; break;
case 'ADMIN_FEE_MINIMUM': ADMIN_FEE_MINIMUM = val; break;
case 'ADMIN_FEE_ZT': ADMIN_FEE_ZT = val; break;
case 'ADMIN_1PWM_PCT': ADMIN_1PWM_PCT = val; break;
case 'VOIP_RATE_BASIC': VOIP_RATE_BASIC = val; break;
case 'VOIP_RATE_STANDARD': VOIP_RATE_STANDARD = val; break;
case 'VOIP_RATE_PREMIUM': VOIP_RATE_PREMIUM = val; break;
case 'VOIP_PHONE_RATE': VOIP_PHONE_RATE = val; break;
case 'VOIP_FAX_RATE': VOIP_FAX_RATE = val; break;
case 'TOOL_COST_PER_USER': TOOL_COST_PER_USER = val; break;
case 'TOOL_COST_PER_ENDPOINT':TOOL_COST_PER_ENDPOINT= val; break;
case 'TOOL_COST_MIN': TOOL_COST_MIN = val; break;
case 'IT_SALARY_1': IT_SALARY_1 = val; break;
case 'IT_SALARY_5': IT_SALARY_5 = val; break;
case 'DISCOUNT_M2M': DISCOUNT_M2M = val; break;
case 'DISCOUNT_12MO': DISCOUNT_12MO = val; break;
case 'DISCOUNT_24MO': DISCOUNT_24MO = val; break;
case 'HST_RATE': HST_RATE = val; break;
default: matched = false; break;
}
if (matched) appliedKeys++;
});
if (!appliedKeys) {
reportPricingFallback('package-prices.csv loaded, but no recognized pricing keys were applied.');
return false;
}
return true;
} catch (e) {
reportPricingFallback(`Could not load package-prices.csv (${e?.message || 'request failed'}).`);
return false;
}
}
// --- CALC ---
// ── calcQuote() ─────────────────────────────────────────────────
// Single source of truth for all pricing.
// Returns a plain object with every calculated value.
// Called once per update() cycle; result q passed to all sub-functions.
// NEVER writes to DOM — pure calculation only.
function calcQuote() {
const byol = document.getElementById('rateBYOL')?.checked || false;
const users = parseInt(document.getElementById('userCount')?.value) || 0;
const endpoints = parseInt(document.getElementById('endpointCount')?.value) || 0;
const servers = parseInt(document.getElementById('serverCount')?.value) || 0;
const addExtHours= document.getElementById('addExtHours')?.checked || false;
const addPWM = document.getElementById('addPWM')?.checked || false;
const addINKY = document.getElementById('addINKY')?.checked || false;
const addZT = document.getElementById('addZT')?.checked || false;
const addUSB = document.getElementById('addUSB')?.checked || false;
const addBMB = document.getElementById('addBMB')?.checked || false;
const ztSeats = parseInt(document.getElementById('ztNetSeats')?.value) || 0;
const ztRouters = parseInt(document.getElementById('ztNetRouters')?.value) || 0;
const voipTier = (document.querySelector('input[name="voipTier"]:checked') || {}).value || 'basic';
const voipSeats = parseInt(document.getElementById('voipSeats')?.value) || 0;
const addVoipPhone = document.getElementById('addVoipPhone')?.checked || false;
const addVoipFax = document.getElementById('addVoipFax')?.checked || false;
const clientName = document.getElementById('clientName')?.value || '';
const contractTerm = (document.querySelector('input[name="contractTerm"]:checked') || {}).value || 'm2m';
const hstEnabled = document.getElementById('hstToggle')?.checked || false;
const oneTimeFee = parseFloat(document.getElementById('oneTimeFee')?.value) || 0;
const ztActive = addZT || ztSeats > 0;
const adminWaived = document.getElementById('adminWaived')?.checked || false;
const VOIP_RATES = { basic: VOIP_RATE_BASIC, standard: VOIP_RATE_STANDARD, premium: VOIP_RATE_PREMIUM };
const CONTRACT_DISCOUNT = { 'm2m': DISCOUNT_M2M, '12mo': DISCOUNT_12MO, '24mo': DISCOUNT_24MO };
const baseUserRate = byol ? RATE_BYOL : RATE_M365;
const addonRate = (addExtHours ? ADDON_EXT_HOURS : 0) + (addPWM ? ADDON_1PASSWORD : 0) + (addINKY ? ADDON_INKY : 0) + (addZT ? ADDON_ZERO_TRUST_USER : 0);
const totalUserRate= baseUserRate + addonRate;
const userBase = users * baseUserRate;
const userExt = addExtHours ? users * ADDON_EXT_HOURS : 0;
const userPWM = addPWM ? users * ADDON_1PASSWORD : 0;
const userINKY = addINKY ? users * ADDON_INKY : 0;
const userZT = addZT ? users * ADDON_ZERO_TRUST_USER : 0;
const userTotal = userBase + userExt + userPWM + userINKY + userZT;
const endpointBase = endpoints * RATE_ENDPOINT;
const serverBase = servers * RATE_SERVER;
const endpointUSB = addUSB ? endpoints * ADDON_USB_BLOCKING : 0;
const endpointBMB = addBMB ? endpoints * ADDON_BARE_METAL_BACKUP : 0;
const endpointTotal= endpointBase + serverBase + endpointUSB + endpointBMB;
const baseSubtotal = userBase + endpointBase + serverBase;
const siteAdminBase = Math.max(ADMIN_FEE_FLOOR, ADMIN_FEE_MINIMUM - baseSubtotal);
const admin1PWM = addPWM ? Math.round(userPWM * ADMIN_1PWM_PCT) : 0;
const adminFeeNet = siteAdminBase + (ztActive ? ADMIN_FEE_ZT : 0) + admin1PWM;
const adminFeeEffective = adminWaived ? 0 : adminFeeNet;
const ztNetSeats = ztSeats * ZT_SEAT_RATE;
const ztNetRouters = ztRouters * ZT_ROUTER_RATE;
const ztNetTotal = ztNetSeats + ztNetRouters;
const voipSeatRate = VOIP_RATES[voipTier] || VOIP_RATE_BASIC;
const voipSeatsAmt = voipSeats * voipSeatRate;
const voipPhoneAmt = addVoipPhone ? voipSeats * VOIP_PHONE_RATE : 0;
const voipFaxAmt = addVoipFax ? voipSeats * VOIP_FAX_RATE : 0;
const voipTotal = voipSeatsAmt + voipPhoneAmt + voipFaxAmt;
const MRR = userTotal + endpointTotal + adminFeeEffective + ztNetTotal + voipTotal;
const annual = MRR * 12;
const perUserAllin = users > 0 ? MRR / users : 0;
// Contract term discount
const discountPct = CONTRACT_DISCOUNT[contractTerm] || 0;
const discountAmt = Math.round(MRR * discountPct);
const effectiveMrr = MRR - discountAmt;
const effectiveAnnual = effectiveMrr * 12;
// HST (Ontario 13%)
const hstAmt = hstEnabled ? Math.round(effectiveMrr * HST_RATE) : 0;
const mrrWithHst = effectiveMrr + hstAmt;
// Per-user cost breakdown: user-driven services vs site overhead spread across users
const perUserServices = users > 0 ? Math.round(userTotal / users) : 0;
const perUserSiteOvhd = users > 0 ? Math.round((effectiveMrr - userTotal) / users) : 0;
return {
users, endpoints, servers, ztSeats, ztRouters, voipSeats, voipTier, addVoipPhone, addVoipFax,
byol, addPWM, addINKY, addExtHours, addZT, addUSB, addBMB, ztActive, adminWaived, clientName,
contractTerm, hstEnabled, oneTimeFee,
baseUserRate, totalUserRate,
userBase, userPWM, userINKY, userExt, userZT, userTotal,
endpointBase, serverBase, endpointUSB, endpointBMB, endpointTotal,
ztNetSeats, ztNetRouters, ztNetTotal,
voipSeatRate, voipSeatsAmt, voipPhoneAmt, voipFaxAmt, voipTotal,
baseSubtotal, siteAdminBase, admin1PWM, adminFeeNet,
MRR, annual, perUserAllin,
discountPct, discountAmt, effectiveMrr, effectiveAnnual,
hstAmt, mrrWithHst,
perUserServices, perUserSiteOvhd
};
}
// --- MAIN UPDATE ---
// ── update() ─────────────────────────────────────────────────────
// Master DOM update function. Called on every input change.
// Sequence: calcQuote → update all displays → renderNudge →
// updateSavings → updateVsComparison → updateSectionSummaries
// Also wrapped by the mobile IIFE below to sync _m elements.
// Do not call renderNudge() directly from other functions;
// always go through update() to keep _m panel in sync.
function update() {
const q = calcQuote();
const m365BundleSavings = Math.max(0, RATE_M365 - RATE_BYOL);
// ── Onboarding fee: auto = 50% MRR unless manually set or waived ──
// 12-month and 24-month contracts auto-waive the onboarding fee.
const waivedEl = document.getElementById('onboardingWaived');
const feeEl = document.getElementById('oneTimeFee');
if (waivedEl && (q.contractTerm === '12mo' || q.contractTerm === '24mo')) {
waivedEl.checked = true;
waivedEl.disabled = true;
waivedEl.dataset.autoWaived = '1';
if (feeEl) delete feeEl.dataset.manual;
} else if (waivedEl) {
waivedEl.disabled = false;
if (waivedEl.dataset.autoWaived) {
waivedEl.checked = false;
delete waivedEl.dataset.autoWaived;
}
}
const waived = waivedEl?.checked || false;
let oneTimeFee;
if (waived) {
oneTimeFee = 0;
if (feeEl) { feeEl.value = ''; feeEl.disabled = true; feeEl.placeholder = 'Complimentary'; }
} else {
if (feeEl) { feeEl.disabled = false; feeEl.placeholder = 'auto'; }
if (feeEl && !feeEl.dataset.manual) {
oneTimeFee = Math.round(q.MRR / 2);
feeEl.value = oneTimeFee > 0 ? oneTimeFee : '';
} else {
oneTimeFee = parseFloat(feeEl?.value) || 0;
}
}
q.oneTimeFee = oneTimeFee;
const { users, endpoints, servers, voipSeats, voipTier,
byol, addPWM, addINKY, addExtHours, addZT, addUSB, addBMB, ztActive, adminWaived, clientName,
contractTerm, hstEnabled,
baseUserRate,
userPWM, userINKY, userExt, userZT, userTotal,
serverBase, endpointUSB, endpointBMB, endpointTotal,
ztNetTotal,
voipTotal,
baseSubtotal, siteAdminBase, admin1PWM, adminFeeNet,
MRR,
discountPct, discountAmt, effectiveMrr, effectiveAnnual,
hstAmt, mrrWithHst,
perUserServices, perUserSiteOvhd } = q;
// Client name display
['clientNameDisplay', 'clientNameDisplay_m'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.textContent = clientName || 'Client Name';
el.classList.toggle('placeholder', !clientName);
});
// Admin Fee
if (adminWaived) {
getEl('adminFeeDisplay').innerHTML =
`<span class="admin-fee-strike">${fmt(adminFeeNet)}/mo</span> <span class="admin-fee-waived-badge">WAIVED</span>`;
} else {
getEl('adminFeeDisplay').textContent = fmt(adminFeeNet) + '/mo';
}
const fillPct = Math.min(100, Math.max(0, (baseSubtotal / ADMIN_FEE_MINIMUM) * 100));
getEl('floorBar').style.width = fillPct + '%';
getEl('floorProgress').textContent = fmt(baseSubtotal) + ' / ' + fmt(ADMIN_FEE_MINIMUM);
const atFloor = baseSubtotal >= ADMIN_FEE_MINIMUM;
getEl('floorBar').style.background = atFloor ? 'var(--green)' : 'var(--accent)';
getEl('floorNote').textContent = atFloor
? `✓ Minimum threshold reached — site fee at floor (${fmt(ADMIN_FEE_FLOOR)}/mo)`
: `$${Math.max(0, ADMIN_FEE_MINIMUM - baseSubtotal).toLocaleString()} more in services reduces admin fee further`;
getEl('fb-base').textContent = fmt(siteAdminBase);
getEl('fb-zt-row').classList.toggle('hidden', !ztActive);
getEl('fb-zt').textContent = '+' + fmt(ADMIN_FEE_ZT);
getEl('fb-pwm-row').classList.toggle('hidden', !addPWM);
getEl('fb-pwm').textContent = '+' + fmt(admin1PWM);
if (adminWaived) {
getEl('fb-total').innerHTML =
`<span class="admin-fee-strike">${fmt(adminFeeNet)}</span> <span class="admin-fee-waived-badge">WAIVED</span>`;
} else {
getEl('fb-total').textContent = fmt(adminFeeNet);
}
// Savings callout below fee table
const adminWaivedSavingsEl = document.getElementById('adminWaivedSavings');
if (adminWaivedSavingsEl) {
adminWaivedSavingsEl.classList.toggle('hidden', !adminWaived || adminFeeNet === 0);
const awAmt = document.getElementById('adminWaivedAmt');
if (awAmt) awAmt.textContent = fmt(adminFeeNet);
}
// Sidebar lines
const show = (id, val) => {
const el = getEl(id); if (!el) return;
el.classList.toggle('hidden', !val);
};
show('sl-users', users > 0);
getEl('sl-users-sub')?.classList.toggle('hidden', users === 0);
if (users > 0) {
getEl('sl-users-val').textContent = fmt(userTotal);
const sub = getEl('sl-users-sub');
sub.classList.remove('hidden');
const subParts = [`${users} × ${fmt(baseUserRate)}/user (${byol ? 'BYOL' : 'M365 Incl.'})`];
if (addExtHours) subParts.push(`+ ${fmt(userExt)}/mo ext. hrs`);
if (addPWM) subParts.push(`+ ${fmt(userPWM)}/mo 1Password`);
if (addINKY) subParts.push(`+ ${fmt(userINKY)}/mo INKY Pro upgrade`);
if (addZT) subParts.push(`+ ${fmt(userZT)}/mo Zero Trust`);
sub.innerHTML = subParts.join('<br>');
}
show('sl-endpoints', endpoints > 0);
getEl('sl-endpoints-sub')?.classList.toggle('hidden', endpoints === 0);
if (endpoints > 0) {
// endpointTotal includes serverBase — display endpoints-only so servers line doesn't double-count
const epOnly = endpointTotal - serverBase;
getEl('sl-endpoints-val').textContent = fmt(epOnly);
const sub = getEl('sl-endpoints-sub');
sub.classList.remove('hidden');
const epParts = [`${endpoints} × ${fmt(RATE_ENDPOINT)}/endpoint`];
if (addBMB) epParts.push(`+ ${fmt(endpointBMB)}/mo Bare Metal Backup`);
if (addUSB) epParts.push(`+ ${fmt(endpointUSB)}/mo USB Blocking`);
sub.innerHTML = epParts.join('<br>');
}
show('sl-servers', servers > 0);
if (servers > 0) getEl('sl-servers-val').textContent = fmt(serverBase);
show('sl-zt', ztNetTotal > 0);
if (ztNetTotal > 0) getEl('sl-zt-val').textContent = fmt(ztNetTotal);
show('sl-voip', voipTotal > 0);
if (voipTotal > 0) getEl('sl-voip-val').textContent = fmt(voipTotal);
const slAdminEl = getEl('sl-admin');
const slAdminValEl = getEl('sl-admin-val');
const slAdminSubEl = getEl('sl-admin-sub');
if (adminWaived) {
slAdminEl?.classList.add('sl-admin-waived');
if (slAdminValEl) slAdminValEl.innerHTML =
`<span class="otf-amt">${fmt(adminFeeNet)}</span> <span class="otf-waived-label">WAIVED</span>`;
} else {
slAdminEl?.classList.remove('sl-admin-waived');
if (slAdminValEl) slAdminValEl.textContent = fmt(adminFeeNet);
}
if (slAdminSubEl) {
const adminParts = [`Base ${fmt(siteAdminBase)}/mo`];
if (ztActive) adminParts.push(`+ ${fmt(ADMIN_FEE_ZT)}/mo Zero Trust supplement`);
if (addPWM && admin1PWM > 0) adminParts.push(`+ ${fmt(admin1PWM)}/mo 1Password admin`);
slAdminSubEl.classList.remove('hidden');
slAdminSubEl.innerHTML = adminParts.join('<br>');
}
// MRR + totals — show effective MRR (after term discount) as the headline number
getEl('mrrDisplay').textContent = fmt(effectiveMrr);
getEl('annualDisplay').textContent = fmt(effectiveAnnual);
getEl('perUserRow').classList.toggle('hidden', users === 0);
if (users > 0) getEl('perUserDisplay').textContent = fmt(effectiveMrr / users) + '/user';
// Discount row (only shown when a term discount is active)
const discountRow = getEl('sl-discount-row');
if (discountRow) {
discountRow.classList.toggle('hidden', discountPct === 0);
if (discountPct > 0) {
const termLabel = contractTerm === '12mo' ? '12-Month' : '24-Month';
const discEl = getEl('sl-discount-val');
if (discEl) discEl.textContent = `${fmt(discountAmt)}/mo (${termLabel}, ${Math.round(discountPct*100)}% off)`;
}
}
// Base MRR (pre-discount) row — shown only when discount is active
const baseMrrRow = getEl('sl-base-mrr-row');
if (baseMrrRow) baseMrrRow.classList.toggle('hidden', discountPct === 0);
const baseMrrEl = getEl('sl-base-mrr-val');
if (baseMrrEl && discountPct > 0) baseMrrEl.textContent = fmt(MRR) + '/mo';
// HST row + Total row
getEl('sl-hst-row')?.classList.toggle('hidden', !hstEnabled);
getEl('sl-hst-total-row')?.classList.toggle('hidden', !hstEnabled);
if (hstEnabled) {
const hstEl = getEl('sl-hst-val');
if (hstEl) hstEl.textContent = `+${fmt(hstAmt)}`;
const totalEl = getEl('sl-hst-total-val');
if (totalEl) totalEl.textContent = fmt(mrrWithHst) + '/mo';
}
// Onboarding fee row — show fee, or "WAIVED" savings if waived
const _waived = document.getElementById('onboardingWaived')?.checked || false;
const _wouldBe = Math.round(q.MRR / 2);
['sl-otf-row', 'sl-otf-row_m'].forEach(rowId => {
const row = getEl(rowId);
if (!row) return;
const isMobile = rowId.endsWith('_m');
const valEl = getEl(isMobile ? 'sl-otf-val_m' : 'sl-otf-val');
if (_waived && _wouldBe > 0) {
row.classList.remove('hidden');
row.classList.add('sl-otf-waived');
if (valEl) valEl.innerHTML = '<span class="otf-amt">' + fmt(_wouldBe) + '</span> <span class="otf-waived-label">WAIVED</span>';
} else if (oneTimeFee > 0) {
row.classList.remove('hidden', 'sl-otf-waived');
if (valEl) valEl.textContent = fmt(oneTimeFee) + ' (one-time)';
} else {
row.classList.add('hidden');
row.classList.remove('sl-otf-waived');
}
});
// Per-user breakdown sub-line
const puBreakdown = getEl('perUserBreakdown');
if (puBreakdown) {
puBreakdown.classList.toggle('hidden', users === 0);
if (users > 0) {
puBreakdown.textContent = `${fmt(perUserServices)} user services + ${fmt(perUserSiteOvhd)} site overhead`;
}
}
// Sidebar notes
getEl('sideNote-m365').classList.toggle('hidden', byol);
getEl('sideNote-byol').classList.toggle('hidden', !byol);
if (!byol && users > 0) getEl('m365SaveAmt').textContent = fmt(users * m365BundleSavings);
// BYOL callouts
getEl('byolCalloutGreen').classList.toggle('hidden', byol);
getEl('byolCalloutRed').classList.toggle('hidden', !byol);
getEl('userIncluded').classList.toggle('byol-mode', byol);
if (byol) getEl('byolRedSavings').textContent = fmt(users * m365BundleSavings);
// VoIP tier active state
['basic','standard','premium'].forEach(t => {
const seg = getEl('seg-' + t);
if (seg) seg.classList.toggle('active', t === voipTier);
});
// Contract term active state
['m2m','12mo','24mo'].forEach(t => {
const seg = getEl('seg-term-' + t);
if (seg) seg.classList.toggle('active', t === contractTerm);
});
// ── Contract savings display ─────────────────────────────────
const savingsRow = document.getElementById('qsSavingsDisplay');
const savingsAmt = document.getElementById('qsSavingsAmt');
if (savingsRow && savingsAmt) {
if (q.discountAmt > 0) {
savingsAmt.textContent = fmt(q.discountAmt);
savingsRow.classList.remove('hidden');
} else {
savingsRow.classList.add('hidden');
}
}
// Nudges — dynamic dollar values, context-sensitive conditions
const nudges = [];
if (!addZT && users > 0) nudges.push({
text: `Zero Trust Deny By Default adds enterprise-grade access control — ${fmt(users * ADDON_ZERO_TRUST_USER)}/mo for all ${users} user${users !== 1 ? 's' : ''}. Recommended for any client with remote staff or sensitive data.`,
color: 'amber'
});
if (!addPWM && users > 0) nudges.push({
text: `1Password Management — one vault for every credential, enforce strong passwords, revoke access instantly when staff leave. Only ${fmt(users * ADDON_1PASSWORD)}/mo for ${users} user${users !== 1 ? 's' : ''}.`,
color: 'green'
});
if (byol && users > 0) nudges.push({
text: `BYOL selected — switching to M365 Included (${fmt(RATE_M365)}/user) bundles the license and saves the client up to ${fmt(users * m365BundleSavings)}/mo vs retail Microsoft 365 pricing.`,
color: 'green'
});
if (endpoints > 0 && !addBMB) nudges.push({
text: `Bare Metal Backup protects against ransomware with image-level restore — ${fmt(endpoints * ADDON_BARE_METAL_BACKUP)}/mo covers all ${endpoints} endpoint${endpoints !== 1 ? 's' : ''}. Fast, bare-metal recovery if the worst happens.`,
color: 'amber'
});
if (voipSeats > 0 && voipTier === 'basic') nudges.push({
text: `Standard tier adds HD video, call analytics, and advanced call routing — only ${fmt(voipSeats * (VOIP_RATE_STANDARD - VOIP_RATE_BASIC))}/mo more for ${voipSeats} seat${voipSeats !== 1 ? 's' : ''}. Most clients upgrade within 6 months.`,
color: 'green'
});
if (servers > 0 && !addBMB) nudges.push({
text: `Servers are in scope but Bare Metal Backup is not selected — adding BMB on ${endpoints} endpoint${endpoints !== 1 ? 's' : ''} would also cover server backup in a complete DR story.`,
color: 'amber'
});
if (users > 0 && endpoints > 0 && endpoints > users * 1.5) nudges.push({
text: `${endpoints} endpoints vs ${users} users — that's a high endpoint-to-user ratio. Consider whether unmanaged devices should be attached to user seats to ensure full coverage.`,
color: 'amber'
});
_nudges = nudges;
if (_nudgeIndex == null || _nudgeIndex >= nudges.length) _nudgeIndex = 0;
renderNudge();
updateSavings(q);
updateVsComparison(q);
updateSectionSummaries(q);
// Highlight addon preview pills when their add-on is selected
document.querySelectorAll('.addon-preview-pill[data-addon]').forEach(pill => {
const cb = document.getElementById(pill.dataset.addon);
pill.classList.toggle('active', cb?.checked || false);
});
debouncedSave();
}
// ── onWaiveToggle() ──────────────────────────────────────────────
// Called from onchange on #onboardingWaived checkbox.
// Clears the manual override flag on the fee input so auto-calc resumes,
// then runs update(). Extracted from inline HTML attribute for clarity.
function onWaiveToggle() {
const feeInput = document.getElementById('oneTimeFee');
if (feeInput) feeInput.removeAttribute('data-manual');
update();
}
// ── toggleSection(id) ────────────────────────────────────────────
// Collapses/expands a numbered section card.
// Adds/removes .sec-open on the section element.
// .sec-open → chevron rotates 180deg (CSS), body shown (JS display).
// Calls updateSectionSummaries() to show/hide summary badges.
// Map: section ID → collapsible IDs that should auto-expand when section opens
const _sectionCollapsibles = {
'sec-02': ['userIncluded', 'addonsA'],
'sec-03': ['endpointIncluded', 'addonsB'],
'sec-04': ['serverIncluded'],
};
function toggleSection(id) {
const section = document.getElementById(id);
const body = document.getElementById(id + '-body');
if (!section || !body) return;
const isOpen = section.classList.toggle('sec-open');
body.style.display = isOpen ? '' : 'none';
// Auto-expand inner collapsibles when section opens
if (isOpen && _sectionCollapsibles[id]) {
_sectionCollapsibles[id].forEach(cid => {
const cBody = document.getElementById(cid);
const cIcon = document.getElementById(cid + '-icon');
const cPreview = document.getElementById(cid + '-preview');
if (cBody && !cBody.classList.contains('open')) {
cBody.classList.add('open');
if (cIcon) cIcon.classList.add('open');
if (cPreview) cPreview.style.display = 'none';
}
});
}
updateSectionSummaries(calcQuote());
updateToggleAllBtn();
}
// ── toggleAllSections() / updateToggleAllBtn() ────────────────────
// Collapse all if any are open; expand all if all are closed.
// Button label reflects current state.
const _allSecIds = ['sec-01','sec-02','sec-03','sec-04','sec-05','sec-06'];
function toggleAllSections() {
const anyOpen = _allSecIds.some(id => document.getElementById(id)?.classList.contains('sec-open'));
_allSecIds.forEach(id => {
const section = document.getElementById(id);
const body = document.getElementById(id + '-body');
if (!section || !body) return;
if (anyOpen) { section.classList.remove('sec-open'); body.style.display = 'none'; }
else {
section.classList.add('sec-open'); body.style.display = '';
// Auto-expand inner collapsibles
if (_sectionCollapsibles[id]) {
_sectionCollapsibles[id].forEach(cid => {
const cBody = document.getElementById(cid);
const cIcon = document.getElementById(cid + '-icon');
const cPreview = document.getElementById(cid + '-preview');
if (cBody && !cBody.classList.contains('open')) {
cBody.classList.add('open');
if (cIcon) cIcon.classList.add('open');
if (cPreview) cPreview.style.display = 'none';
}
});
}
}
});
updateSectionSummaries(calcQuote());
updateToggleAllBtn();
}
function updateToggleAllBtn() {
const anyOpen = _allSecIds.some(id => document.getElementById(id)?.classList.contains('sec-open'));
const btn = document.getElementById('toggleAllBtn');
if (!btn) return;
const collapseSpan = btn.querySelector('.toggle-all-collapse-icon');
const expandSpan = btn.querySelector('.toggle-all-expand-icon');
const textSpan = btn.querySelector('.toggle-all-label');
if (collapseSpan) collapseSpan.style.display = anyOpen ? '' : 'none';
if (expandSpan) expandSpan.style.display = anyOpen ? 'none' : '';
if (textSpan) textSpan.textContent = anyOpen ? 'Collapse All' : 'Expand All';
}
// ── stepCount(inputId, delta, event) ─────────────────────────────
// Increments or decrements a num-input by delta (±1) from the
// collapsed section counter. Stops propagation so the click doesn't
// bubble up and toggle the section open/closed.
function stepCount(inputId, delta, event) {
if (event) event.stopPropagation();
const el = document.getElementById(inputId);
if (!el) return;
const min = parseInt(el.min) >= 0 ? parseInt(el.min) : 0;
el.value = Math.max(min, (parseInt(el.value) || 0) + delta);
update();
}
// ── updateSectionSummaries(q) ────────────────────────────────────
// Shows/hides summary badges on collapsed sections.
// setSummary(id, text) → display:inline-block when section collapsed
// and text is non-empty; display:none otherwise.
// Called by update() and toggleSection().
function updateSectionSummaries(q) {
if (!q) q = calcQuote(); // fallback only; always pass q explicitly from update()/toggleSection()
const collapsed = id => !document.getElementById(id)?.classList.contains('sec-open');
const setSummary = (id, text) => {
const el = document.getElementById(id);
if (!el) return;
const secId = id.replace('-summary','').replace('sec0','sec-0');
const show = collapsed(secId) && !!text;
el.textContent = text || '';
el.style.display = show ? 'inline-block' : 'none';
};
setSummary('sec01-summary', q.adminWaived ? 'WAIVED' : fmt(q.adminFeeNet) + '/mo');
setSummary('sec02-summary', q.users > 0 ? `${q.users} user${q.users !== 1 ? 's' : ''} · ${fmt(q.userTotal)}/mo` : '');
setSummary('sec03-summary', q.endpoints > 0 ? `${q.endpoints} endpoint${q.endpoints !== 1 ? 's' : ''} · ${fmt(q.endpointTotal - q.serverBase)}/mo` : '');
setSummary('sec04-summary', q.servers > 0 ? `${q.servers} server${q.servers !== 1 ? 's' : ''} · ${fmt(q.serverBase)}/mo` : '');
const ztItems = [];
if (q.ztSeats > 0) ztItems.push(`${q.ztSeats} seat${q.ztSeats !== 1 ? 's' : ''}`);
if (q.ztRouters > 0) ztItems.push(`${q.ztRouters} device${q.ztRouters !== 1 ? 's' : ''}`);
setSummary('sec05-summary', q.ztNetTotal > 0 ? ztItems.join(' · ') + ` · ${fmt(q.ztNetTotal)}/mo` : '');
setSummary('sec06-summary', q.voipSeats > 0 ? `${q.voipSeats} seat${q.voipSeats !== 1 ? 's' : ''} · ${fmt(q.voipTotal)}/mo` : '');
}
// ── toggleCollapsible(id) ─────────────────────────────────────────
// Collapses/expands inner content panels (What's Included, Add-Ons).
// Separate from section-level toggle. Toggles .open on .collapsible-body.
// Also toggles preview pills (shown when collapsed, hidden when open).
function toggleCollapsible(id) {
const body = document.getElementById(id);
const icon = document.getElementById(id + '-icon');
const preview = document.getElementById(id + '-preview');
if (!body) return;
const open = body.classList.toggle('open');
if (icon) icon.classList.toggle('open', open);
if (preview) preview.style.display = open ? 'none' : 'flex';
}
// ── toggleAddon(checkId, rowId) ─────────────────────────────────
// Flips the hidden checkbox + toggles .selected on the visible row.
// Called via onclick on the <label class="addon-row"> element.
// The label's onclick fires BEFORE the native checkbox change,
// so we manually invert cb.checked here instead of reading it.
function toggleAddon(checkId, rowId) {
const cb = document.getElementById(checkId);
const row = document.getElementById(rowId);
cb.checked = !cb.checked;
if (row) row.classList.toggle('selected', cb.checked);
}
// ── activateTier(tier) ───────────────────────────────────────────
// Sets the active VoIP tier pill (basic/standard/premium).
// Adds .active class to matching .tier-seg, removes from others.
// Called from onclick on tier labels (alongside update()).
function activateTier(tier) {
const el = document.getElementById('voip' + tier.charAt(0).toUpperCase() + tier.slice(1));
if (el) { el.checked = true; update(); }
}
// ── updateVsComparison(q) ────────────────────────────────────────
// Renders the "VS Hiring In-House" comparison table in the sidebar.
// Shown only when users > 0 OR endpoints > 0.
// Compares annualised SVS MRR vs 1-person ($85K+tools) and
// 5-person team ($420K+tools). Rows turn amber when SVS costs more.
// Updates both desktop (#vsComparison) and mobile (_m) via syncClass.
function updateVsComparison(q) {
const vsEl = document.getElementById('vsComparison');
if (!vsEl) return;
q = q || calcQuote();
const { users, endpoints, effectiveAnnual } = q;
if (users < 1 && endpoints < 1) { vsEl.classList.add('hidden'); return; }
vsEl.classList.remove('hidden');
const toolsMonthly = Math.max(TOOL_COST_MIN, (users * TOOL_COST_PER_USER) + (endpoints * TOOL_COST_PER_ENDPOINT));
const toolsAnnual = toolsMonthly * 12;
const cost1 = IT_SALARY_1 + toolsAnnual;
const cost5 = IT_SALARY_5 + toolsAnnual;
const save1 = cost1 - effectiveAnnual;
const save5 = cost5 - effectiveAnnual;
const fmtK = n => n >= 0 ? '$' + Math.round(n/1000) + 'K/yr' : '$' + Math.round(Math.abs(n)/1000) + 'K/yr';
getEl('vs-svs-annual').textContent = '$' + Math.round(effectiveAnnual/1000) + 'K/yr';
getEl('vs-1man-cost').textContent = '$' + Math.round(cost1/1000) + 'K/yr';
getEl('vs-5man-cost').textContent = '$' + Math.round(cost5/1000) + 'K/yr';
const applyVsRow = (rowId, saveId, labelId, saving) => {
const row = getEl(rowId);
const val = getEl(saveId);
const lbl = getEl(labelId);
if (!row || !val || !lbl) return;
val.textContent = fmtK(saving);
row.classList.remove('vs-save-green', 'vs-save-amber');
val.classList.remove('vs-val-green', 'vs-val-amber');
lbl.classList.remove('vs-val-green', 'vs-val-amber');
if (saving > 0) {
row.classList.add('vs-save-green');
val.classList.add('vs-val-green');
lbl.classList.add('vs-val-green');
lbl.textContent = 'YOU SAVE';
} else {
row.classList.add('vs-save-amber');
val.classList.add('vs-val-amber');
lbl.classList.add('vs-val-amber');
lbl.textContent = 'Costs more';
}
};
applyVsRow('vs-1man-save-row', 'vs-1man-save', 'vs-1man-save-lbl', save1);
applyVsRow('vs-5man-save-row', 'vs-5man-save', 'vs-5man-save-lbl', save5);
const toolsLabel = toolsMonthly <= TOOL_COST_MIN
? `min $${TOOL_COST_MIN}/mo`
: `~$${toolsMonthly}/mo`;
getEl('vs-footnote').textContent = `Based on ~$${Math.round(IT_SALARY_1/1000)}K Ottawa IT salary + ${toolsLabel} tool licensing (M365, EDR, RMM, backup, SAT & more). No benefits, hiring, or turnover costs factored.`;
}
// ── renderNudge() ─────────────────────────────────────────────────
// Renders the active nudge insight in BOTH sidebar banners.
// Reads module-scoped _nudges[] and _nudgeIndex.
// applyNudge('') → desktop #nudgeBanner
// applyNudge('_m') → mobile panel #nudgeBanner_m
// Always call renderNudge() not applyNudge() directly.
function renderNudge() {
const nudges = _nudges;
const idx = _nudgeIndex;
function applyNudge(suffix) {
const s = suffix || '';
const banner = document.getElementById('nudgeBanner' + s);
if (!banner) return;
if (!nudges.length) { banner.classList.add('hidden'); return; }
const n = nudges[idx];
banner.classList.remove('hidden', 'amber', 'green');
banner.classList.add(n.color);
const textEl = document.getElementById('nudgeText' + s);
const cntEl = document.getElementById('nudgeCounter' + s);
if (textEl) textEl.textContent = n.text;
if (cntEl) cntEl.textContent = nudges.length > 1 ? `${idx + 1}/${nudges.length}` : '';
const btns = banner.querySelectorAll('button');
btns.forEach(b => b.style.display = nudges.length >= 1 ? 'flex' : 'none');
}
applyNudge('');
applyNudge('_m');
}
// ── cycleNudge(dir) ──────────────────────────────────────────────
// Manual nudge navigation. dir: +1 (next) or -1 (prev).
// Does NOT reset the 30s auto-rotation timer (intentional).
function cycleNudge(dir) {
if (!_nudges.length) return;
_nudgeIndex = (_nudgeIndex + dir + _nudges.length) % _nudges.length;
renderNudge();
}
// ── startNudgeRotation() ─────────────────────────────────────────
// Starts 30-second auto-advance timer for nudge insights.
// Clears existing timer first to prevent duplicates.
// Called once on page load. Timer advances index directly
// (does not call cycleNudge) so manual nav doesn't reset it.
function startNudgeRotation() {
if (_nudgeTimer) clearInterval(_nudgeTimer);
_nudgeTimer = setInterval(() => {
if (_nudges.length > 1) {
_nudgeIndex = (_nudgeIndex + 1) % _nudges.length;
renderNudge();
}
}, 30000);
}
startNudgeRotation();
// ── updateSavings(q) ─────────────────────────────────────────────
// Renders VoIP phone bill savings comparator (Section VI).
// Reads #currentPhoneBill input. Shows green savings or amber
// warning depending on whether VoIP total is lower or higher.
// q is optional — falls back to calcQuote() if called bare
// (e.g. from oninput on #currentPhoneBill directly).
function updateSavings(q) {
const billEl = document.getElementById('currentPhoneBill');
const comparator = document.getElementById('savingsComparator');
const prompt = document.getElementById('savingsPrompt');
if (!billEl || !comparator) return;
const bill = parseFloat(billEl.value) || 0;
q = q || calcQuote();
const { voipTotal, voipSeats } = q;
if (voipSeats < 1) { comparator.classList.add('hidden'); if(prompt) prompt.style.display='none'; return; }
if (prompt) prompt.style.display = bill === 0 ? '' : 'none';
if (bill === 0) { comparator.classList.add('hidden'); return; }
comparator.classList.remove('hidden');
const saving = bill - voipTotal;
if (saving > 0) {
comparator.textContent = `✓ Switching to SVS VoIP saves ~${fmt(saving)}/mo (${fmt(saving*12)}/yr) vs your current bill of ${fmt(bill)}/mo`;
comparator.classList.remove('savings-amber');
} else {
comparator.textContent = `Your current bill (${fmt(bill)}/mo) is lower than this VoIP quote (${fmt(voipTotal)}/mo) — consider reviewing the tier or seat count.`;
comparator.classList.add('savings-amber');
}
}
// ── HELPERS ──────────────────────────────────────────────────────
// fmt(n) — formats number as $1,234 string
// getEl(id) — shorthand for document.getElementById(id)
// stepInput — increment/decrement a num-input by delta, respects min
function fmt(n) { return '$' + Math.round(n).toLocaleString('en-US'); }
function getEl(id) { return document.getElementById(id); }
function stepInput(id, delta) {
const el = document.getElementById(id);
if (!el) return;
const min = parseInt(el.min) ?? 0;
el.value = Math.max(min, (parseInt(el.value) || 0) + delta);
update();
}
// --- AUTO-SAVE / RESTORE ---
const SAVE_KEY = 'svs-msp-quote-v1';
const QUOTE_REF_KEY = 'svs-msp-quote-ref';
function saveState() {
try {
const state = {
clientName: document.getElementById('clientName')?.value || '',
users: parseInt(document.getElementById('userCount')?.value) || 0,
endpoints: parseInt(document.getElementById('endpointCount')?.value) || 0,
servers: parseInt(document.getElementById('serverCount')?.value) || 0,
byol: document.getElementById('rateBYOL')?.checked || false,
addExtHours: document.getElementById('addExtHours')?.checked || false,
addPWM: document.getElementById('addPWM')?.checked || false,
addINKY: document.getElementById('addINKY')?.checked || false,
addZT: document.getElementById('addZT')?.checked || false,
addUSB: document.getElementById('addUSB')?.checked || false,
addBMB: document.getElementById('addBMB')?.checked || false,
ztSeats: parseInt(document.getElementById('ztNetSeats')?.value) || 0,
ztRouters: parseInt(document.getElementById('ztNetRouters')?.value) || 0,
voipTier: (document.querySelector('input[name="voipTier"]:checked') || {}).value || 'basic',
voipSeats: parseInt(document.getElementById('voipSeats')?.value) || 0,
addVoipPhone: document.getElementById('addVoipPhone')?.checked || false,
addVoipFax: document.getElementById('addVoipFax')?.checked || false,
phoneBill: parseFloat(document.getElementById('currentPhoneBill')?.value) || 0,
contractTerm: (document.querySelector('input[name="contractTerm"]:checked') || {}).value || 'm2m',
hstEnabled: document.getElementById('hstToggle')?.checked || false,
oneTimeFee: parseFloat(document.getElementById('oneTimeFee')?.value) || 0,
onboardingWaived: document.getElementById('onboardingWaived')?.checked || false,
onboardingManual: document.getElementById('oneTimeFee')?.dataset.manual === '1',
};
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
} catch(e) { console.warn('saveState: failed to persist quote', e); }
}
let _saveTimer;
function debouncedSave() {
clearTimeout(_saveTimer);
_saveTimer = setTimeout(saveState, 400);
}
function syncBodyScrollLock() {
const panelOpen = document.getElementById('mobileQuotePanel')?.classList.contains('open');
const modalOpen = document.getElementById('resetConfirmModal')?.classList.contains('open');
document.body.style.overflow = (panelOpen || modalOpen) ? 'hidden' : '';
}
function openResetConfirm() {
const modal = document.getElementById('resetConfirmModal');
if (!modal) return;
modal.classList.add('open');
modal.setAttribute('aria-hidden', 'false');
syncBodyScrollLock();
document.getElementById('resetConfirmCancel')?.focus();
}
function closeResetConfirm() {
const modal = document.getElementById('resetConfirmModal');
if (!modal) return;
modal.classList.remove('open');
modal.setAttribute('aria-hidden', 'true');
syncBodyScrollLock();
}
function confirmResetQuote() {
clearTimeout(_saveTimer);
try {
localStorage.removeItem(SAVE_KEY);
localStorage.removeItem(QUOTE_REF_KEY);
} catch (e) {
console.warn('confirmResetQuote: failed to clear saved quote state', e);
}
closeResetConfirm();
window.location.reload();
}
document.addEventListener('keydown', function(e) {
const modalOpen = document.getElementById('resetConfirmModal')?.classList.contains('open');
if (e.key === 'Escape' && modalOpen) {
closeResetConfirm();
e.preventDefault();
e.stopImmediatePropagation();
}
});
// ── restoreState() ───────────────────────────────────────────────
// Restores form state from localStorage on page load.
// SAVE_KEY = 'svs-msp-quote-v1'.
function restoreState() {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return false;
const s = JSON.parse(raw);
const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; };
const check = (id, val) => { const el = document.getElementById(id); if (el) el.checked = !!val; };
set('clientName', s.clientName);
set('userCount', s.users);
set('endpointCount', s.endpoints);
set('serverCount', s.servers);
check('rateBYOL', s.byol);
check('rateM365', !s.byol);
check('addExtHours', s.addExtHours);
check('addPWM', s.addPWM);
check('addINKY', s.addINKY);
check('addZT', s.addZT);
check('addUSB', s.addUSB);
check('addBMB', s.addBMB);
set('ztNetSeats', s.ztSeats);
set('ztNetRouters', s.ztRouters);
const tierEl = document.querySelector(`input[name="voipTier"][value="${s.voipTier}"]`);
if (tierEl) tierEl.checked = true;
set('voipSeats', s.voipSeats);
check('addVoipPhone',s.addVoipPhone);
check('addVoipFax', s.addVoipFax);
set('currentPhoneBill', s.phoneBill);
const termEl = document.querySelector(`input[name="contractTerm"][value="${s.contractTerm || 'm2m'}"]`);
if (termEl) termEl.checked = true;
check('hstToggle', s.hstEnabled);
check('onboardingWaived', s.onboardingWaived);
if (s.onboardingManual && !s.onboardingWaived) {
set('oneTimeFee', s.oneTimeFee || 0);
const feeEl = document.getElementById('oneTimeFee');
if (feeEl) feeEl.dataset.manual = '1';
}
// Restore addon row selected states
const rowMap = { addExtHours:'row-ext', addPWM:'row-pwm', addINKY:'row-inky', addZT:'row-zt', addBMB:'row-bmb', addUSB:'row-usb', addVoipPhone:'row-vphone', addVoipFax:'row-vfax' };
['addExtHours','addPWM','addINKY','addZT','addBMB','addUSB','addVoipPhone','addVoipFax'].forEach(id => {
const cb = document.getElementById(id);
if (cb?.checked) {
const row = document.getElementById(rowMap[id]);
if (row) row.classList.add('selected');
}
});
const cn = document.getElementById('clientName');
const cd = document.getElementById('clientNameDisplay');
if (cn && cd) cd.textContent = cn.value || 'Client Name';
return true;
} catch(e) { return false; }
}
// ── printInvoice() ────────────────────────────────────────────────
// Generates a clean invoice-style HTML document in a new window
// and triggers the browser print dialog (Save as PDF works perfectly).
function printInvoice() {
const q = calcQuote();
const waived = document.getElementById('onboardingWaived')?.checked || false;
const feeEl = document.getElementById('oneTimeFee');
const onboardingFee = waived ? 0 : (parseFloat(feeEl?.value) || Math.round(q.MRR / 2));
const waivedAmt = Math.round(q.MRR / 2);
const quoteRef = document.getElementById('quoteRef')?.textContent || '—';
const quoteDate = document.getElementById('headerDate')?.textContent || '—';
const client = q.clientName || 'Client';
const termLabel = q.contractTerm === '12mo' ? '12-Month Contract — 3% off MRR'
: q.contractTerm === '24mo' ? '24-Month Contract — 5% off MRR'
: 'Month-to-Month';
// ── Build line items ───────────────────────────────────────────
const rows = [];
const row = (label, detail, amt, sub) => rows.push({label, detail, amt, sub: !!sub});
if (q.users > 0) {
const pkg = q.byol ? 'BYOL — Bring Your Own License' : 'M365 Premium Included (Identity, Email & Protection)';
row(`User Package — ${pkg}`, `${q.users} user${q.users!==1?'s':''} × ${fmt(q.baseUserRate)}/mo`, fmt(q.userBase));
if (q.userExt > 0) row(`↳ Extended Hours (+${fmt(ADDON_EXT_HOURS)}/user)`, '', fmt(q.userExt), true);
if (q.userPWM > 0) row(`↳ 1Password Business (+${fmt(ADDON_1PASSWORD)}/user)`, '', fmt(q.userPWM), true);
if (q.userINKY > 0) row(`↳ INKY Pro Upgrade (+${fmt(ADDON_INKY)}/user)`, '', fmt(q.userINKY), true);
if (q.userZT > 0) row(`↳ Zero Trust User (+${fmt(ADDON_ZERO_TRUST_USER)}/user)`, '', fmt(q.userZT), true);
}
if (q.endpoints > 0) {
row('Endpoint Management', `${q.endpoints} endpoint${q.endpoints!==1?'s':''} × ${fmt(RATE_ENDPOINT)}/mo`, fmt(q.endpointBase));
if (q.endpointUSB > 0) row(`↳ USB Blocking (+${fmt(ADDON_USB_BLOCKING)}/endpoint)`, '', fmt(q.endpointUSB), true);
if (q.endpointBMB > 0) row(`↳ Bare Metal Backup (+${fmt(ADDON_BARE_METAL_BACKUP)}/endpoint)`, '', fmt(q.endpointBMB), true);
}
if (q.servers > 0) {
row('Server Management', `${q.servers} server${q.servers!==1?'s':''} × ${fmt(RATE_SERVER)}/mo`, fmt(q.serverBase));
}
if (q.ztNetTotal > 0) {
row('Zero Trust Networking — HaaS', '', fmt(q.ztNetTotal));
if (q.ztNetSeats > 0) row(`↳ ZT Seats (${q.ztSeats} × ${fmt(ZT_SEAT_RATE)}/mo)`, '', fmt(q.ztNetSeats), true);
if (q.ztNetRouters > 0) row(`↳ HaaS Routers (${q.ztRouters} × ${fmt(ZT_ROUTER_RATE)}/mo)`, '', fmt(q.ztNetRouters), true);
}
if (q.voipTotal > 0) {
const tier = {basic:'Basic',standard:'Standard',premium:'Premium'}[q.voipTier] || 'Basic';
row(`VoIP / UCaaS — ${tier}`, `${q.voipSeats} seat${q.voipSeats!==1?'s':''} × $${q.voipSeatRate}/mo`, fmt(q.voipSeatsAmt));
if (q.voipPhoneAmt > 0) row(`↳ Desk Phone HaaS (+${fmt(VOIP_PHONE_RATE)}/seat)`, '', fmt(q.voipPhoneAmt), true);
if (q.voipFaxAmt > 0) row(`↳ Virtual Fax (+${fmt(VOIP_FAX_RATE)}/mo)`, '', fmt(q.voipFaxAmt), true);
}
if (q.adminWaived) {
row('Site Admin Fee', `Tenant, network, documentation & vendor management (waived; normally ${fmt(q.adminFeeNet)}/mo)`, fmt(0));
} else {
row('Site Admin Fee', 'Tenant, network, documentation & vendor management', fmt(q.adminFeeNet));
}
row(`↳ Base Site Admin`, '', fmt(q.siteAdminBase), true);
if (q.ztActive) {
row(`↳ Zero Trust Supplement`, '', fmt(ADMIN_FEE_ZT), true);
}
if (q.addPWM && q.admin1PWM > 0) {
row(`↳ 1Password Management (${Math.round(ADMIN_1PWM_PCT * 100)}%)`, '', fmt(q.admin1PWM), true);
}
const itemsHTML = rows.map(r => `
<tr${r.sub?' class="sub"':''}>
<td class="lbl">${r.label}</td>
<td class="det">${r.detail}</td>
<td class="amt">${r.amt}/mo</td>
</tr>`).join('');
// ── Build configuration summary ────────────────────────────────
const features = [];
const feat = (name, active, detail) => features.push({ name, active, detail: detail || '' });
feat('Licensing Model', true, q.byol ? 'BYOL — Bring Your Own License' : 'M365 Premium Included');
feat('Extended Help Desk Hours', q.addExtHours, q.addExtHours ? `+${fmt(ADDON_EXT_HOURS)}/user/mo` : '');
feat('1Password Business', q.addPWM, q.addPWM ? `+${fmt(ADDON_1PASSWORD)}/user/mo` : '');
feat('INKY Pro Upgrade', q.addINKY, q.addINKY ? `+${fmt(ADDON_INKY)}/user/mo` : '');
feat('Zero Trust User Access', q.addZT, q.addZT ? `+${fmt(ADDON_ZERO_TRUST_USER)}/user/mo` : '');
feat('USB Device Blocking', q.addUSB, q.addUSB ? `+${fmt(ADDON_USB_BLOCKING)}/endpoint/mo` : '');
feat('Bare Metal Backup', q.addBMB, q.addBMB ? `+${fmt(ADDON_BARE_METAL_BACKUP)}/endpoint/mo` : '');
feat('Zero Trust Networking (HaaS)', q.ztNetTotal > 0, q.ztNetTotal > 0 ? `${q.ztSeats} seats, ${q.ztRouters} routers` : '');
feat('VoIP / UCaaS', q.voipTotal > 0, q.voipTotal > 0 ? `${({basic:'Basic',standard:'Standard',premium:'Premium'})[q.voipTier]} — ${q.voipSeats} seats` : '');
feat('Desk Phone HaaS', q.addVoipPhone, q.addVoipPhone ? `+${fmt(VOIP_PHONE_RATE)}/seat/mo` : '');
feat('Virtual Fax', q.addVoipFax, q.addVoipFax ? `+${fmt(VOIP_FAX_RATE)}/mo` : '');
const configHTML = features.map(f => `
<div class="cfg-item${f.active ? '' : ' cfg-inactive'}">
<span class="cfg-icon">${f.active ? '&#10003;' : '&#10005;'}</span>
<span class="cfg-name">${f.name}</span>
${f.active && f.detail ? `<span class="cfg-detail">${f.detail}</span>` : ''}
${!f.active ? '<span class="cfg-not-inc">Not Included</span>' : ''}
</div>`).join('');
// ── Build totals ───────────────────────────────────────────────
let totals = '';
if (q.discountPct > 0) {
totals += `<tr class="t-muted"><td colspan="2">Base MRR</td><td>${fmt(q.MRR)}/mo</td></tr>`;
totals += `<tr class="t-muted"><td colspan="2">Term Discount (${Math.round(q.discountPct*100)}% off)</td><td>${fmt(q.discountAmt)}/mo</td></tr>`;
}
totals += `<tr class="t-mrr"><td colspan="2">Monthly Recurring (MRR)</td><td>${fmt(q.effectiveMrr)}/mo</td></tr>`;
if (q.users > 0) {
totals += `<tr class="t-muted"><td colspan="2">Per-User Effective Cost</td><td>${fmt(q.effectiveMrr / q.users)}/user/mo</td></tr>`;
totals += `<tr class="t-muted"><td colspan="2" style="padding-left:20px;font-size:11px">${fmt(q.perUserServices)} user services + ${fmt(q.perUserSiteOvhd)} site overhead</td><td></td></tr>`;
}
if (q.hstEnabled) {
totals += `<tr class="t-muted"><td colspan="2">Ontario HST (${Math.round(HST_RATE * 100)}%)</td><td>+${fmt(q.hstAmt)}/mo</td></tr>`;
totals += `<tr class="t-total"><td colspan="2">Total Monthly</td><td>${fmt(q.mrrWithHst)}/mo</td></tr>`;
}
if (waived && waivedAmt > 0) {
totals += `<tr class="t-waived"><td colspan="2">Onboarding Fee <span class="badge">WAIVED</span>${q.contractTerm!=='m2m'?' — included with '+termLabel.split(' —')[0]:''}</td><td>${fmt(waivedAmt)} saved</td></tr>`;
} else if (onboardingFee > 0) {
totals += `<tr class="t-muted"><td colspan="2">Onboarding Fee (one-time)</td><td>${fmt(onboardingFee)}</td></tr>`;
}
totals += `<tr class="t-annual"><td colspan="2">Annual Projection</td><td>${fmt(q.effectiveAnnual)}/yr</td></tr>`;
// ── SVG logo (inline) ──────────────────────────────────────────
const logo = `<svg height="30" viewBox="0 0 424.27 97.38" xmlns="http://www.w3.org/2000/svg"><polyline points="7.32 8.88 62.11 8.88 34.72 58.22" fill="#1f75a6"/><polyline points="40.7 55.33 64.4 12.64 71.88 12.64 44.48 61.99 40.7 55.33" fill="#8d252f"/><path d="M110.03,89.91c-5.99,0-11.27-.66-15.86-1.99-4.59-1.33-8.6-3.2-12.05-5.63-3.45-2.42-6.54-5.27-9.27-8.53l14.21-15.92c3.79,4.85,7.75,8.05,11.88,9.61,4.13,1.55,8.01,2.33,11.65,2.33,1.44,0,2.73-.13,3.87-.4,1.14-.26,2.01-.7,2.62-1.31.61-.61.91-1.44.91-2.5,0-.98-.32-1.82-.97-2.5-.65-.68-1.5-1.27-2.56-1.76-1.06-.49-2.22-.91-3.47-1.25-1.25-.34-2.46-.62-3.64-.85-1.18-.23-2.22-.46-3.13-.68-4.55-1.06-8.53-2.35-11.94-3.87-3.41-1.51-6.25-3.33-8.53-5.46-2.27-2.12-3.96-4.55-5.06-7.28-1.1-2.73-1.65-5.8-1.65-9.21,0-3.87.89-7.39,2.67-10.57,1.78-3.18,4.17-5.91,7.16-8.19,2.99-2.27,6.4-4.02,10.23-5.23,3.83-1.21,7.79-1.82,11.88-1.82,5.99,0,10.99.55,15.01,1.65,4.02,1.1,7.39,2.67,10.12,4.72,2.73,2.05,5.08,4.43,7.05,7.16l-14.32,13.76c-1.67-1.59-3.41-2.9-5.23-3.92-1.82-1.02-3.7-1.78-5.63-2.27-1.93-.49-3.85-.74-5.74-.74-1.74,0-3.22.13-4.43.4-1.21.27-2.16.68-2.84,1.25-.68.57-1.02,1.35-1.02,2.33s.43,1.8,1.31,2.44c.87.64,1.97,1.19,3.3,1.65,1.32.46,2.65.82,3.98,1.08,1.33.27,2.44.47,3.35.62,4.17.76,8,1.8,11.48,3.13,3.49,1.33,6.54,3,9.15,5,2.61,2.01,4.62,4.51,6.03,7.5,1.4,3,2.1,6.54,2.1,10.63,0,5.84-1.46,10.73-4.38,14.67-2.92,3.94-6.84,6.92-11.77,8.92-4.93,2.01-10.42,3.01-16.48,3.01Z" fill="#1a1816"/><path d="M174.27,88.77l-31.72-79.58h24.56l12.73,34.79c.91,2.35,1.65,4.36,2.22,6.03.57,1.67,1.08,3.24,1.54,4.72.46,1.48.89,3.05,1.31,4.72.42,1.67.89,3.71,1.42,6.14h-3.98c.76-3.18,1.42-5.8,1.99-7.84.57-2.05,1.21-4.07,1.93-6.08.72-2.01,1.65-4.56,2.79-7.67l12.73-34.79h23.76l-31.95,79.58h-19.33Z" fill="#1a1816"/><path d="M257.94,89.91c-5.99,0-11.27-.66-15.86-1.99-4.59-1.33-8.6-3.2-12.05-5.63-3.45-2.42-6.54-5.27-9.27-8.53l14.21-15.92c3.79,4.85,7.75,8.05,11.88,9.61,4.13,1.55,8.02,2.33,11.65,2.33,1.44,0,2.73-.13,3.87-.4,1.14-.26,2.01-.7,2.62-1.31.61-.61.91-1.44.91-2.5,0-.98-.32-1.82-.97-2.5-.64-.68-1.5-1.27-2.56-1.76-1.06-.49-2.22-.91-3.47-1.25-1.25-.34-2.46-.62-3.64-.85-1.18-.23-2.22-.46-3.13-.68-4.55-1.06-8.53-2.35-11.94-3.87-3.41-1.51-6.25-3.33-8.53-5.46-2.27-2.12-3.96-4.55-5.06-7.28-1.1-2.73-1.65-5.8-1.65-9.21,0-3.87.89-7.39,2.67-10.57,1.78-3.18,4.17-5.91,7.16-8.19,2.99-2.27,6.4-4.02,10.23-5.23,3.83-1.21,7.79-1.82,11.88-1.82,5.99,0,10.99.55,15.01,1.65,4.02,1.1,7.39,2.67,10.12,4.72,2.73,2.05,5.08,4.43,7.05,7.16l-14.33,13.76c-1.67-1.59-3.41-2.9-5.23-3.92-1.82-1.02-3.69-1.78-5.63-2.27-1.93-.49-3.85-.74-5.74-.74-1.74,0-3.22.13-4.43.4-1.21.27-2.16.68-2.84,1.25-.68.57-1.02,1.35-1.02,2.33s.44,1.8,1.31,2.44c.87.64,1.97,1.19,3.3,1.65,1.33.46,2.65.82,3.98,1.08,1.33.27,2.44.47,3.35.62,4.17.76,8,1.8,11.48,3.13,3.49,1.33,6.54,3,9.15,5,2.62,2.01,4.62,4.51,6.03,7.5,1.4,3,2.1,6.54,2.1,10.63,0,5.84-1.46,10.73-4.38,14.67-2.92,3.94-6.84,6.92-11.77,8.92-4.93,2.01-10.42,3.01-16.48,3.01Z" fill="#1a1816"/><path d="M300.67,47.36V8.02h10.45l14.33,23.33-8.49-.06,14.5-23.27h10.12v39.35h-11.69v-9.39c0-3.37.08-6.41.25-9.11.17-2.7.46-5.38.87-8.04l1.35,3.54-9.5,14.73h-3.71l-9.33-14.73,1.41-3.54c.41,2.51.7,5.09.87,7.73.17,2.64.25,5.78.25,9.42v9.39h-11.69Z" fill="#1a1816"/><path d="M364.36,47.93c-2.96,0-5.57-.33-7.84-.98-2.27-.66-4.25-1.58-5.96-2.78-1.71-1.2-3.23-2.6-4.58-4.22l7.03-7.87c1.87,2.4,3.83,3.98,5.87,4.75,2.04.77,3.96,1.15,5.76,1.15.71,0,1.35-.07,1.91-.2.56-.13.99-.35,1.29-.65.3-.3.45-.71.45-1.24,0-.49-.16-.9-.48-1.24s-.74-.63-1.26-.87c-.53-.24-1.1-.45-1.71-.62-.62-.17-1.22-.31-1.8-.42-.58-.11-1.1-.22-1.55-.34-2.25-.52-4.22-1.16-5.9-1.91-1.69-.75-3.09-1.65-4.22-2.7-1.12-1.05-1.96-2.25-2.5-3.6-.54-1.35-.81-2.87-.81-4.55,0-1.91.44-3.65,1.32-5.23.88-1.57,2.06-2.92,3.54-4.05s3.17-1.99,5.06-2.58,3.85-.9,5.87-.9c2.96,0,5.43.27,7.42.81,1.99.54,3.65,1.32,5,2.33,1.35,1.01,2.51,2.19,3.49,3.54l-7.08,6.8c-.83-.79-1.69-1.43-2.59-1.94s-1.83-.88-2.78-1.12c-.96-.24-1.9-.37-2.84-.37-.86,0-1.59.07-2.19.2-.6.13-1.07.34-1.41.62-.34.28-.51.67-.51,1.15s.21.89.65,1.21c.43.32.97.59,1.63.82.66.22,1.31.4,1.97.53.66.13,1.21.23,1.66.31,2.06.38,3.95.89,5.68,1.55,1.72.66,3.23,1.48,4.53,2.47s2.29,2.23,2.98,3.71c.69,1.48,1.04,3.23,1.04,5.26,0,2.89-.72,5.3-2.16,7.25-1.44,1.95-3.38,3.42-5.82,4.41-2.44.99-5.15,1.49-8.15,1.49Z" fill="#1a1816"/><path d="M386.61,47.36V8.02h17.71c2.7,0,5.1.58,7.2,1.74,2.1,1.16,3.75,2.75,4.95,4.78,1.2,2.02,1.8,4.35,1.8,6.97s-.6,5.17-1.8,7.31c-1.2,2.14-2.85,3.81-4.95,5.03-2.1,1.22-4.5,1.83-7.2,1.83h-5.56v11.69h-12.14ZM398.53,25.33h3.54c.71,0,1.35-.14,1.91-.42.56-.28,1-.68,1.32-1.21.32-.52.48-1.18.48-1.97s-.16-1.42-.48-1.91c-.32-.49-.76-.85-1.32-1.1-.56-.24-1.2-.37-1.91-.37h-3.54v6.97Z" fill="#1a1816"/></svg>`;
const html = `<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8">
<title>SVS MSP Quote — ${client}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&family=DM+Mono:wght@400;500&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Poppins',Arial,sans-serif;font-size:13px;color:#1a1816;background:#fff;padding:52px 56px;max-width:860px;margin:0 auto}
/* ── Header ── */
.hdr{display:flex;justify-content:space-between;align-items:flex-start;padding-bottom:24px;border-bottom:2.5px solid #1a6a98;margin-bottom:32px}
.hdr-meta{text-align:right;font-family:'DM Mono',monospace;font-size:10.5px;color:#6b6360;letter-spacing:.07em;line-height:1.8}
.hdr-meta .ref{font-size:13px;font-weight:500;color:#1a1816;letter-spacing:.09em}
/* ── Client ── */
.client-lbl{font-family:'DM Mono',monospace;font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:#6b6360;margin-bottom:4px}
.client-name{font-size:24px;font-weight:700;color:#1a1816;margin-bottom:6px}
.client-meta{font-family:'DM Mono',monospace;font-size:11px;color:#6b6360}
/* ── Section label ── */
.sec-lbl{font-family:'DM Mono',monospace;font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:#6b6360;margin:28px 0 8px;padding-bottom:6px;border-bottom:1px solid #e8e4db}
/* ── Items table ── */
.items{width:100%;border-collapse:collapse}
.items td{padding:9px 8px;vertical-align:top;border-bottom:1px solid #f0ede8}
.items .lbl{color:#1a1816;width:50%}
.items .det{color:#6b6360;font-family:'DM Mono',monospace;font-size:11px;width:30%}
.items .amt{text-align:right;font-family:'DM Mono',monospace;font-size:12px;color:#1a6a98;width:20%;white-space:nowrap}
.items tr.sub td{padding-top:3px;padding-bottom:3px;font-size:12px;color:#6b6360}
.items tr.sub .amt{color:#8a9aaa}
/* ── Totals table ── */
.tots{width:100%;border-collapse:collapse;margin-top:4px}
.tots td{padding:8px 8px;border-bottom:1px solid #f0ede8;vertical-align:middle}
.tots td:last-child{text-align:right;font-family:'DM Mono',monospace;white-space:nowrap}
.t-muted td{color:#6b6360;font-size:12px}
.t-mrr td{font-weight:600;font-size:15px}
.t-mrr td:last-child{color:#1a6a98;font-size:16px}
.t-total td{font-weight:700;background:#f4f2ed;font-size:15px}
.t-total td:last-child{font-size:17px;color:#1a1816}
.t-waived td{color:#217045}
.t-waived td:last-child{font-family:'DM Mono',monospace}
.t-annual td{color:#6b6360;font-size:12px;border-bottom:none}
.badge{display:inline-block;background:#e8f7ef;color:#217045;font-size:9px;font-family:'DM Mono',monospace;letter-spacing:.1em;padding:1px 6px;border-radius:3px;margin-left:6px;vertical-align:middle}
/* ── Config summary ── */
.cfg-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 24px;margin-bottom:8px}
.cfg-item{display:flex;align-items:baseline;gap:8px;font-size:11.5px;padding:4px 0}
.cfg-icon{font-size:11px;font-weight:700;color:#217045;flex-shrink:0}
.cfg-inactive .cfg-icon{color:#c0392b;font-size:10px;font-weight:400}
.cfg-inactive .cfg-name{color:#999;text-decoration:line-through}
.cfg-name{color:#1a1816}
.cfg-not-inc{font-family:'DM Mono',monospace;font-size:9px;color:#c0392b;letter-spacing:.06em;margin-left:auto;text-transform:uppercase}
.cfg-detail{font-family:'DM Mono',monospace;font-size:10px;color:#6b6360;margin-left:auto}
/* ── Footer ── */
.footer{margin-top:48px;padding-top:18px;border-top:1px solid #e8e4db;display:flex;justify-content:space-between;align-items:flex-end}
.footer-left{font-family:'DM Mono',monospace;font-size:10px;color:#aaa;letter-spacing:.05em;line-height:1.9}
.footer-note{font-size:11px;color:#999;margin-top:8px;max-width:480px;line-height:1.6}
@media print{body{padding:0}@page{margin:1.5cm;size:A4}}
</style></head><body>
<div class="hdr">
${logo}
<div class="hdr-meta">
<div class="ref">${quoteRef}</div>
<div>${quoteDate}</div>
<div style="margin-top:4px;text-transform:uppercase;letter-spacing:.1em">MSP Service Proposal</div>
</div>
</div>
<div class="client-lbl">Prepared for</div>
<div class="client-name">${client}</div>
<div class="client-meta">${termLabel} &nbsp;·&nbsp; ${q.hstEnabled ? 'HST included in figures' : 'Prices in CAD, excl. HST'}</div>
<div class="sec-lbl">Service Breakdown</div>
<table class="items"><tbody>${itemsHTML}</tbody></table>
<div class="sec-lbl">Configuration Summary</div>
<div class="cfg-grid">${configHTML}</div>
<div class="sec-lbl">Quote Summary</div>
<table class="tots"><tbody>${totals}</tbody></table>
<div class="footer">
<div>
<div class="footer-left">SILICON VALLEY SERVICES &nbsp;·&nbsp; OTTAWA, ON</div>
<div class="footer-note">This quote is valid for 30 days from the date above. All prices in CAD. HST at ${Math.round(HST_RATE * 100)}% applies on invoice unless already included. MRR is billed monthly in advance.</div>
</div>
<div style="font-family:'DM Mono',monospace;font-size:10px;color:#bbb;letter-spacing:.06em">${quoteDate}</div>
</div>
<script>window.addEventListener('load',()=>{window.print();})<\/script>
</body></html>`;
const w = window.open('', '_blank', 'width=900,height=700');
if (w) { w.document.write(html); w.document.close(); }
}
// ── exportQuoteJSON() ─────────────────────────────────────────────
// Export B — Structured JSON for tech team provisioning import.
// Triggers a .json file download AND puts the JSON on the clipboard.
// Field names match the tech provisioning checklist format.
function exportQuoteJSON() {
const q = calcQuote();
const ref = document.getElementById('quoteRef')?.textContent || '';
const dt = new Date();
const dateStr = `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`;
const termMap = { 'm2m': 'Month-to-Month', '12mo': '12-Month', '24mo': '24-Month' };
const payload = {
quoteRef: ref,
quoteDate: dateStr,
clientName: q.clientName || '',
contractTerm: termMap[q.contractTerm] || 'Month-to-Month',
licensing: q.byol ? 'BYOL' : 'M365-Included',
users: q.users,
endpoints: q.endpoints,
servers: q.servers,
addons: {
extendedHours: q.addExtHours,
passwordManager: q.addPWM,
inkyPro: q.addINKY,
zeroTrustUsers: q.addZT,
baremetalBackup: q.addBMB,
usbBlocking: q.addUSB
},
zeroTrustNetwork: {
seats: q.ztSeats,
routers: q.ztRouters
},
voip: {
tier: q.voipSeats > 0 ? q.voipTier : null,
seats: q.voipSeats,
phoneHardware: q.addVoipPhone,
faxLine: q.addVoipFax
},
pricing: {
baseMrr: q.MRR,
discountPct: Math.round(q.discountPct * 100),
discountAmt: q.discountAmt,
effectiveMrr: q.effectiveMrr,
annual: q.effectiveAnnual,
oneTimeFee: q.oneTimeFee,
hstIncluded: q.hstEnabled,
hstAmt: q.hstAmt,
mrrWithHst: q.mrrWithHst
}
};
const json = JSON.stringify(payload, null, 2);
const client = (q.clientName || 'Quote').replace(/[^a-z0-9]/gi,'_');
// Download
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `SVS_MSP_Quote_${client}.json`;
a.click();
URL.revokeObjectURL(url);
// Clipboard
navigator.clipboard?.writeText(json).then(() => {
const btn = document.getElementById('btnExportJSON');
if (btn) {
const orig = btn.innerHTML;
btn.innerHTML = '✓ Copied to clipboard';
setTimeout(() => { btn.innerHTML = orig; }, 2000);
}
}).catch(() => {});
}
// ── THEME TOGGLE ─────────────────────────────────────────────────
// Base CSS is the default dark theme.
// Variant themes are imported dynamically on demand:
// light = base + SVS-MSP-Calculator-light.css
// glass = base + SVS-MSP-Calculator-glass.css
// Preference persisted to localStorage under 'svs-theme'.
// initTheme() runs before initQuote() so the UI boots in the saved theme.
const THEME_STORAGE_KEY = 'svs-theme';
const THEME_STYLESHEET_ID = 'themeStylesheetLink';
const THEME_ASSET_VERSION = '20260313-02';
const SVG_SUN = '<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
const SVG_MOON = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
const SVG_GLASS = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l2.7 5.3L20 11l-5.3 2.7L12 19l-2.7-5.3L4 11l5.3-2.7L12 3z"/><path d="M18.5 3.5l.8 1.7L21 6l-1.7.8-.8 1.7-.8-1.7L16 6l1.7-.8.8-1.7z"/></svg>';
const THEME_ORDER = ['dark', 'light', 'glass'];
const THEME_CONFIG = {
dark: {
icon: SVG_MOON,
href: null,
label: 'Dark'
},
light: {
icon: SVG_SUN,
href: 'SVS-MSP-Calculator-light.css',
label: 'Light'
},
glass: {
icon: SVG_GLASS,
href: 'SVS-MSP-Calculator-glass.css',
label: 'Glass'
}
};
function getSavedTheme() {
const saved = localStorage.getItem(THEME_STORAGE_KEY);
return THEME_ORDER.includes(saved) ? saved : 'dark';
}
function getCurrentTheme() {
const applied = document.documentElement.dataset.theme;
return THEME_ORDER.includes(applied) ? applied : getSavedTheme();
}
function updateThemeToggleUi(theme) {
const icon = document.getElementById('themeToggleIcon');
const btn = document.getElementById('themeToggle');
const currentIndex = THEME_ORDER.indexOf(theme);
const nextTheme = THEME_ORDER[(currentIndex + 1) % THEME_ORDER.length];
const currentLabel = THEME_CONFIG[theme].label;
const nextLabel = THEME_CONFIG[nextTheme].label;
if (icon) icon.innerHTML = THEME_CONFIG[theme].icon;
if (btn) {
const uiLabel = `Theme: ${currentLabel}. Click to switch to ${nextLabel}.`;
btn.setAttribute('title', uiLabel);
btn.setAttribute('aria-label', uiLabel);
}
}
function applyTheme(theme) {
const nextTheme = THEME_ORDER.includes(theme) ? theme : 'dark';
const existing = document.getElementById(THEME_STYLESHEET_ID);
const legacyLight = document.getElementById('lightThemeLink');
if (existing) existing.remove();
if (legacyLight) legacyLight.remove();
const themeHref = THEME_CONFIG[nextTheme].href;
if (themeHref) {
const el = document.createElement('link');
el.id = THEME_STYLESHEET_ID;
el.rel = 'stylesheet';
el.href = `${themeHref}?v=${THEME_ASSET_VERSION}`;
el.dataset.theme = nextTheme;
document.head.appendChild(el);
}
document.documentElement.dataset.theme = nextTheme;
localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
updateThemeToggleUi(nextTheme);
}
function toggleTheme() {
const currentTheme = getCurrentTheme();
const currentIndex = THEME_ORDER.indexOf(currentTheme);
const nextTheme = THEME_ORDER[(currentIndex + 1) % THEME_ORDER.length];
applyTheme(nextTheme);
}
function initTheme() {
applyTheme(getSavedTheme());
}
// ── initQuote() ──────────────────────────────────────────────────
// Entry point. Sets quote ref (SVS-YYYYMMDD-XXXX) and date,
// restores saved state, then calls update() for initial render.
// Called once at bottom of script.
async function initQuote() {
await loadPricing();
const now = new Date();
const months = ['JANUARY','FEBRUARY','MARCH','APRIL','MAY','JUNE','JULY','AUGUST','SEPTEMBER','OCTOBER','NOVEMBER','DECEMBER'];
const year = now.getFullYear();
const month = months[now.getMonth()];
const dateStr = `${year}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}`;
const savedRef = localStorage.getItem(QUOTE_REF_KEY);
let quoteRef;
if (savedRef) {
// Regenerate if the baked-in date is older than 30 days
const m = savedRef.match(/^SVS-(\d{4})(\d{2})(\d{2})-/);
const refDate = m ? new Date(+m[1], +m[2] - 1, +m[3]) : null;
const ageMs = refDate ? now - refDate : Infinity;
if (ageMs > 30 * 24 * 60 * 60 * 1000) {
quoteRef = `SVS-${dateStr}-${String(Math.floor(Math.random()*9000)+1000)}`;
localStorage.setItem(QUOTE_REF_KEY, quoteRef);
} else {
quoteRef = savedRef;
}
} else {
quoteRef = `SVS-${dateStr}-${String(Math.floor(Math.random()*9000)+1000)}`;
localStorage.setItem(QUOTE_REF_KEY, quoteRef);
}
const quoteRefEl = document.getElementById('quoteRef');
if (quoteRefEl) quoteRefEl.textContent = quoteRef;
const headerDateEl = document.getElementById('headerDate');
if (headerDateEl) headerDateEl.textContent = `${month} ${year}`;
restoreState();
update();
}
initTheme();
initQuote();
// ── MOBILE SIDEBAR SYNC CONTRACT ──────────────────────────────────────
// The desktop sidebar is the single markup source of truth.
// On boot, we clone it into #mobilePanelContent and suffix all IDs with _m.
// The update() wrapper below then syncs dynamic values/classes into the clone.
//
// WHEN ADDING A NEW SIDEBAR ELEMENT:
// 1. Add the desktop element with its ID (e.g. #my-element)
// 2. Ensure the mobile sync map below includes it if it changes at runtime
// 3. Avoid separate handwritten mobile markup for sidebar content
// ──────────────────────────────────────────────────────────────────────
// ── MOBILE QUOTE PANEL IIFE ──────────────────────────────────────
// Encapsulates all mobile panel logic to avoid polluting global scope.
// ARCHITECTURE:
// The real sidebar lives in .side-col (desktop).
// The mobile panel gets a JS-generated clone with _m suffixed IDs.
// update() is wrapped here to sync _m elements after every update.
// openMobilePanel / closeMobilePanel are exposed on window.
// Do NOT DOM-move the real desktop sidebar into the panel —
// it permanently breaks desktop layout on resize.
(function() {
function buildMobileSidebar() {
var container = document.getElementById('mobilePanelContent');
var desktopSidebar = document.querySelector('.side-col .sidebar');
if (!container || !desktopSidebar || container.children.length) return;
var mobileSidebar = desktopSidebar.cloneNode(true);
mobileSidebar.removeAttribute('id');
mobileSidebar.querySelectorAll('[id]').forEach(function(el) {
el.id = el.id + '_m';
});
var mobileHstToggle = mobileSidebar.querySelector('#hstToggle_m');
if (mobileHstToggle) {
mobileHstToggle.onchange = function() {
var desktopHstToggle = document.getElementById('hstToggle');
if (desktopHstToggle) desktopHstToggle.checked = this.checked;
update();
};
}
var mobileExportJson = mobileSidebar.querySelector('#btnExportJSON_m');
if (mobileExportJson) mobileExportJson.removeAttribute('id');
container.appendChild(mobileSidebar);
}
buildMobileSidebar();
window.openMobilePanel = function() {
var panel = document.getElementById('mobileQuotePanel');
if (panel) {
panel.classList.add('open');
syncBodyScrollLock();
}
};
window.closeMobilePanel = function() {
var panel = document.getElementById('mobileQuotePanel');
if (panel) {
panel.classList.remove('open');
syncBodyScrollLock();
}
};
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') window.closeMobilePanel();
});
window.addEventListener('resize', function() {
if (window.innerWidth > 1100) window.closeMobilePanel();
});
// ── MOBILE PANEL SYNC ────────────────────────────────────────
// syncEl(id) — copies innerHTML from #id to #id_m
// syncClass(id) — copies className from #id to #id_m (hidden/shown state)
// syncStyle(id) — copies style.cssText from #id to #id_m (inline display)
function syncEl(id) {
var src = document.getElementById(id);
var dst = document.getElementById(id + '_m');
if (src && dst) dst.innerHTML = src.innerHTML;
}
function syncClass(id) {
var src = document.getElementById(id);
var dst = document.getElementById(id + '_m');
if (src && dst) dst.className = src.className;
}
function syncStyle(id) {
var src = document.getElementById(id);
var dst = document.getElementById(id + '_m');
if (src && dst) dst.style.cssText = src.style.cssText;
}
function syncChecked(id) {
var src = document.getElementById(id);
var dst = document.getElementById(id + '_m');
if (src && dst) dst.checked = src.checked;
}
var sidebarSyncMap = {
html: [
'clientNameDisplay',
'sl-users-val',
'sl-endpoints-val',
'sl-servers-val',
'sl-zt-val',
'sl-voip-val',
'sl-admin-val',
'mrrDisplay',
'annualDisplay',
'perUserDisplay',
'perUserBreakdown',
'm365SaveAmt',
'sl-discount-val',
'sl-base-mrr-val',
'sl-hst-val',
'sl-hst-total-val',
'sl-otf-val',
'vs-svs-annual',
'vs-1man-cost',
'vs-1man-save',
'vs-1man-save-lbl',
'vs-5man-cost',
'vs-5man-save',
'vs-5man-save-lbl',
'vs-footnote',
'nudgeText',
'nudgeCounter',
'sl-users-sub',
'sl-endpoints-sub',
'sl-admin-sub',
'adminWaivedAmt'
],
class: [
'sl-users',
'sl-users-sub',
'sl-endpoints',
'sl-endpoints-sub',
'sl-admin-sub',
'sl-servers',
'sl-zt',
'sl-voip',
'sl-admin',
'sideNote-m365',
'sideNote-byol',
'vsComparison',
'perUserRow',
'perUserBreakdown',
'sl-discount-row',
'sl-base-mrr-row',
'sl-hst-row',
'sl-hst-total-row',
'sl-otf-row',
'vs-1man-save-row',
'vs-1man-save',
'vs-1man-save-lbl',
'vs-5man-save-row',
'vs-5man-save',
'vs-5man-save-lbl',
'nudgeBanner',
'adminWaivedSavings'
],
style: [
'sl-users-sub',
'sl-endpoints-sub',
'sl-admin-sub',
'perUserRow'
],
checked: [
'hstToggle'
]
};
function runSidebarSync(ids, syncFn) {
ids.forEach(syncFn);
}
// ── UPDATE WRAPPER ─────────────────────────────────────────────
// Wraps the global update() to also sync the mobile panel.
// _origUpdate = the real update() defined above.
// After _origUpdate() runs, all _m IDs are synced.
// Do not call _origUpdate() directly anywhere else.
var _origUpdate = window.update;
window.update = function() {
_origUpdate();
runSidebarSync(sidebarSyncMap.html, syncEl);
runSidebarSync(sidebarSyncMap.class, syncClass);
runSidebarSync(sidebarSyncMap.style, syncStyle);
runSidebarSync(sidebarSyncMap.checked, syncChecked);
// Pill MRR — show effective MRR with label
var mrr = document.getElementById('mrrDisplay');
var pill = document.getElementById('mobilePillMrr');
if (mrr && pill) pill.textContent = mrr.textContent;
};
// Run one full sync now to populate panel on initial load
window.update();
})();