1244 lines
65 KiB
JavaScript
1244 lines
65 KiB
JavaScript
// ── 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%
|
||
|
||
// ── loadPricing() ────────────────────────────────────────────────
|
||
// Fetches package-prices.csv and overrides the pricing variables above.
|
||
// Silently falls back to built-in defaults if CSV is missing or malformed.
|
||
async function loadPricing() {
|
||
try {
|
||
const res = await fetch('package-prices.csv');
|
||
if (!res.ok) return;
|
||
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;
|
||
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;
|
||
}
|
||
});
|
||
} catch (e) {
|
||
// CSV unavailable — built-in defaults remain active
|
||
}
|
||
}
|
||
|
||
// --- 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 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 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 ? VOIP_FAX_RATE : 0;
|
||
const voipTotal = voipSeatsAmt + voipPhoneAmt + voipFaxAmt;
|
||
|
||
const MRR = userTotal + endpointTotal + adminFeeNet + 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, 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();
|
||
|
||
// ── 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, 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
|
||
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 ($150/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-pwm-row').classList.toggle('hidden', !addPWM);
|
||
getEl('fb-pwm').textContent = '+' + fmt(admin1PWM);
|
||
getEl('fb-total').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);
|
||
if (users > 0) {
|
||
getEl('sl-users-val').textContent = fmt(userTotal);
|
||
const sub = getEl('sl-users-sub');
|
||
sub.style.display = '';
|
||
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`);
|
||
if (addZT) subParts.push(`+ ${fmt(userZT)}/mo Zero Trust`);
|
||
sub.innerHTML = subParts.join('<br>');
|
||
}
|
||
show('sl-endpoints', 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.style.display = '';
|
||
const epParts = [`${endpoints} × $35/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);
|
||
getEl('sl-admin-val').textContent = fmt(adminFeeNet);
|
||
|
||
// MRR + totals — show effective MRR (after term discount) as the headline number
|
||
getEl('mrrDisplay').textContent = fmt(effectiveMrr);
|
||
getEl('annualDisplay').textContent = fmt(effectiveAnnual);
|
||
getEl('perUserRow').style.display = users > 0 ? '' : 'none';
|
||
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';
|
||
}
|
||
// Sync mobile HST toggle state
|
||
const hstToggleMobile = document.getElementById('hstToggle_m');
|
||
if (hstToggleMobile) hstToggleMobile.checked = hstEnabled;
|
||
|
||
// 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 * 15);
|
||
|
||
// BYOL callouts
|
||
getEl('byolCalloutGreen').classList.toggle('hidden', byol);
|
||
getEl('byolCalloutRed').classList.toggle('hidden', !byol);
|
||
if (byol) getEl('byolRedSavings').textContent = fmt(users * 15);
|
||
|
||
// 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 * 55)}/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 * 9)}/mo for ${users} user${users !== 1 ? 's' : ''}.`,
|
||
color: 'green'
|
||
});
|
||
if (byol && users > 0) nudges.push({
|
||
text: `BYOL selected — switching to M365 Included ($130/user) bundles the license and saves the client up to ${fmt(users * 15)}/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 * 25)}/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'
|
||
});
|
||
window._nudges = nudges;
|
||
if (window._nudgeIndex == null || window._nudgeIndex >= nudges.length) window._nudgeIndex = 0;
|
||
|
||
renderNudge();
|
||
updateSavings(q);
|
||
updateVsComparison(q);
|
||
updateSectionSummaries(q);
|
||
debouncedSave();
|
||
}
|
||
|
||
// ── 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.
|
||
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';
|
||
updateSectionSummaries();
|
||
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 = ''; }
|
||
});
|
||
updateSectionSummaries();
|
||
updateToggleAllBtn();
|
||
}
|
||
function updateToggleAllBtn() {
|
||
const anyOpen = _allSecIds.some(id => document.getElementById(id)?.classList.contains('sec-open'));
|
||
const btn = document.getElementById('toggleAllBtn');
|
||
if (btn) btn.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) {
|
||
q = q || calcQuote();
|
||
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', 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.className = row.className.replace(/\bvs-save-green\b|\bvs-save-amber\b/g, '').trim();
|
||
val.className = val.className.replace(/\bvs-val-green\b|\bvs-val-amber\b/g, '').trim();
|
||
lbl.className = lbl.className.replace(/\bvs-val-green\b|\bvs-val-amber\b/g, '').trim();
|
||
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);
|
||
|
||
getEl('vs-footnote').textContent = `Based on ~$85K Ottawa IT salary (2024) + min $650/mo 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 window._nudges[] and window._nudgeIndex.
|
||
// applyNudge('') → desktop #nudgeBanner
|
||
// applyNudge('_m') → mobile panel #nudgeBanner_m
|
||
// Always call renderNudge() not applyNudge() directly.
|
||
function renderNudge() {
|
||
const nudges = window._nudges || [];
|
||
const idx = window._nudgeIndex || 0;
|
||
|
||
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) {
|
||
const nudges = window._nudges || [];
|
||
if (!nudges.length) return;
|
||
window._nudgeIndex = ((window._nudgeIndex || 0) + 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 (window._nudgeTimer) clearInterval(window._nudgeTimer);
|
||
window._nudgeTimer = setInterval(() => {
|
||
const nudges = window._nudges || [];
|
||
if (nudges.length > 1) {
|
||
window._nudgeIndex = ((window._nudgeIndex || 0) + 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.style.color = '';
|
||
} 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.style.color = 'var(--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';
|
||
|
||
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) {}
|
||
}
|
||
let _saveTimer;
|
||
function debouncedSave() {
|
||
clearTimeout(_saveTimer);
|
||
_saveTimer = setTimeout(saveState, 400);
|
||
}
|
||
|
||
// ── 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
|
||
['addExtHours','addPWM','addINKY','addZT','addBMB','addUSB','addVoipPhone','addVoipFax'].forEach(id => {
|
||
const cb = document.getElementById(id);
|
||
if (cb?.checked) {
|
||
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' };
|
||
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 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 (+$25/user)', '', fmt(q.userExt), true);
|
||
if (q.userPWM > 0) row('↳ 1Password Business (+$9/user)', '', fmt(q.userPWM), true);
|
||
if (q.userINKY > 0) row('↳ Inky Email Security (+$5/user)', '', fmt(q.userINKY), true);
|
||
if (q.userZT > 0) row('↳ Zero Trust User (+$55/user)', '', fmt(q.userZT), true);
|
||
}
|
||
if (q.endpoints > 0) {
|
||
row('Endpoint Management', `${q.endpoints} endpoint${q.endpoints!==1?'s':''} × $35/mo`, fmt(q.endpointBase));
|
||
if (q.endpointUSB > 0) row('↳ USB Blocking (+$4/endpoint)', '', fmt(q.endpointUSB), true);
|
||
if (q.endpointBMB > 0) row('↳ Bare Metal Backup (+$25/endpoint)', '', fmt(q.endpointBMB), true);
|
||
}
|
||
if (q.servers > 0) {
|
||
row('Server Management', `${q.servers} server${q.servers!==1?'s':''} × $120/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} × $25/mo)`, '', fmt(q.ztNetSeats), true);
|
||
if (q.ztNetRouters > 0) row(`↳ HaaS Routers (${q.ztRouters} × $100/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 (+$15/seat)', '', fmt(q.voipPhoneAmt), true);
|
||
if (q.voipFaxAmt > 0) row('↳ Virtual Fax (+$10/mo)', '', fmt(q.voipFaxAmt), true);
|
||
}
|
||
row('Site Admin Fee', 'Tenant, network, documentation & vendor management', fmt(q.adminFeeNet));
|
||
|
||
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 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.hstEnabled) {
|
||
totals += `<tr class="t-muted"><td colspan="2">Ontario HST (13%)</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}
|
||
/* ── 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} · ${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">Quote Summary</div>
|
||
<table class="tots"><tbody>${totals}</tbody></table>
|
||
|
||
<div class="footer">
|
||
<div>
|
||
<div class="footer-left">SILICON VALLEY SERVICES · OTTAWA, ON</div>
|
||
<div class="footer-note">This quote is valid for 30 days from the date above. All prices in CAD. HST at 13% 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.textContent;
|
||
btn.textContent = '✓ Copied to clipboard';
|
||
setTimeout(() => { btn.textContent = orig; }, 2000);
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
|
||
// ── THEME TOGGLE ─────────────────────────────────────────────────
|
||
// Light theme is a separate CSS file imported dynamically on demand.
|
||
// Dark mode = base stylesheet only (no extra link element).
|
||
// Light mode = base + SVS-MSP-Calculator-light.css.
|
||
// Preference persisted to localStorage under 'svs-theme'.
|
||
// initTheme() called first so the page never flashes the wrong theme.
|
||
|
||
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>';
|
||
|
||
function toggleTheme() {
|
||
const link = document.getElementById('lightThemeLink');
|
||
const icon = document.getElementById('themeToggleIcon');
|
||
const btn = document.getElementById('themeToggle');
|
||
if (link) {
|
||
// Currently light → switch to dark
|
||
link.remove();
|
||
localStorage.setItem('svs-theme', 'dark');
|
||
if (icon) icon.innerHTML = SVG_SUN;
|
||
if (btn) btn.setAttribute('title', 'Switch to light theme');
|
||
} else {
|
||
// Currently dark → switch to light
|
||
const el = document.createElement('link');
|
||
el.id = 'lightThemeLink';
|
||
el.rel = 'stylesheet';
|
||
el.href = 'SVS-MSP-Calculator-light.css';
|
||
document.head.appendChild(el);
|
||
localStorage.setItem('svs-theme', 'light');
|
||
if (icon) icon.innerHTML = SVG_MOON;
|
||
if (btn) btn.setAttribute('title', 'Switch to dark theme');
|
||
}
|
||
}
|
||
|
||
function initTheme() {
|
||
if (localStorage.getItem('svs-theme') === 'light') {
|
||
const el = document.createElement('link');
|
||
el.id = 'lightThemeLink';
|
||
el.rel = 'stylesheet';
|
||
el.href = 'SVS-MSP-Calculator-light.css';
|
||
document.head.appendChild(el);
|
||
const icon = document.getElementById('themeToggleIcon');
|
||
const btn = document.getElementById('themeToggle');
|
||
if (icon) icon.innerHTML = SVG_MOON;
|
||
if (btn) btn.setAttribute('title', 'Switch to dark theme');
|
||
}
|
||
}
|
||
|
||
// ── 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('svs-msp-quote-ref');
|
||
const quoteRef = savedRef || `SVS-${dateStr}-${String(Math.floor(Math.random()*9000)+1000)}`;
|
||
if (!savedRef) localStorage.setItem('svs-msp-quote-ref', quoteRef);
|
||
document.getElementById('quoteRef').textContent = quoteRef;
|
||
document.getElementById('headerDate').textContent = `${month} ${year}`;
|
||
restoreState();
|
||
update();
|
||
}
|
||
|
||
initTheme();
|
||
initQuote();
|
||
|
||
// ── MOBILE QUOTE PANEL IIFE ──────────────────────────────────────
|
||
// Encapsulates all mobile panel logic to avoid polluting global scope.
|
||
// ARCHITECTURE:
|
||
// The real sidebar lives in .side-col (desktop).
|
||
// The panel contains a STATIC DUPLICATE 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 sidebar into the panel —
|
||
// it permanently breaks desktop layout on resize.
|
||
(function() {
|
||
// Panel uses a static duplicate sidebar (_m IDs) — no DOM moving needed.
|
||
|
||
window.openMobilePanel = function() {
|
||
var panel = document.getElementById('mobileQuotePanel');
|
||
if (panel) {
|
||
panel.classList.add('open');
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
};
|
||
|
||
window.closeMobilePanel = function() {
|
||
var panel = document.getElementById('mobileQuotePanel');
|
||
if (panel) {
|
||
panel.classList.remove('open');
|
||
document.body.style.overflow = '';
|
||
}
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
// ── 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();
|
||
// Sync all mirrored sidebar elements
|
||
syncEl('clientNameDisplay');
|
||
syncEl('sl-users-val');
|
||
syncEl('sl-endpoints-val');
|
||
syncEl('sl-servers-val');
|
||
syncEl('sl-zt-val');
|
||
syncEl('sl-voip-val');
|
||
syncEl('sl-admin-val');
|
||
syncEl('mrrDisplay');
|
||
syncEl('annualDisplay');
|
||
syncEl('perUserDisplay');
|
||
syncEl('perUserBreakdown');
|
||
syncEl('m365SaveAmt');
|
||
syncEl('sl-discount-val');
|
||
syncEl('sl-base-mrr-val');
|
||
syncEl('sl-hst-val');
|
||
syncEl('sl-hst-total-val');
|
||
syncEl('sl-otf-val');
|
||
syncEl('vs-svs-annual');
|
||
syncEl('vs-1man-cost');
|
||
syncEl('vs-1man-save');
|
||
syncEl('vs-1man-save-lbl');
|
||
syncEl('vs-5man-cost');
|
||
syncEl('vs-5man-save');
|
||
syncEl('vs-5man-save-lbl');
|
||
syncEl('vs-footnote');
|
||
syncEl('nudgeText');
|
||
syncEl('nudgeCounter');
|
||
syncEl('sl-users-sub');
|
||
syncEl('sl-endpoints-sub');
|
||
syncClass('sl-users');
|
||
syncClass('sl-users-sub');
|
||
syncClass('sl-endpoints');
|
||
syncClass('sl-endpoints-sub');
|
||
syncClass('sl-servers');
|
||
syncClass('sl-zt');
|
||
syncClass('sl-voip');
|
||
syncClass('sl-admin');
|
||
syncClass('sideNote-m365');
|
||
syncClass('sideNote-byol');
|
||
syncClass('vsComparison');
|
||
syncClass('perUserRow');
|
||
syncClass('perUserBreakdown');
|
||
syncClass('sl-discount-row');
|
||
syncClass('sl-base-mrr-row');
|
||
syncClass('sl-hst-row');
|
||
syncClass('sl-hst-total-row');
|
||
syncClass('sl-otf-row');
|
||
syncClass('vs-1man-save-row');
|
||
syncClass('vs-1man-save');
|
||
syncClass('vs-1man-save-lbl');
|
||
syncClass('vs-5man-save-row');
|
||
syncClass('vs-5man-save');
|
||
syncClass('vs-5man-save-lbl');
|
||
syncClass('nudgeBanner');
|
||
syncStyle('sl-users-sub');
|
||
syncStyle('sl-endpoints-sub');
|
||
syncStyle('perUserRow');
|
||
// 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();
|
||
})();
|