Before Theme Change

This commit is contained in:
2026-03-13 10:24:58 -04:00
parent 1663c69c63
commit ac9420c812
4 changed files with 411 additions and 213 deletions

View File

@@ -40,13 +40,46 @@ 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.
// Silently falls back to built-in defaults if CSV is missing or malformed.
// 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');
if (!res.ok) return;
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 => {
@@ -55,6 +88,7 @@ async function loadPricing() {
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;
@@ -86,10 +120,18 @@ async function loadPricing() {
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) {
// CSV unavailable — built-in defaults remain active
reportPricingFallback(`Could not load package-prices.csv (${e?.message || 'request failed'}).`);
return false;
}
}
@@ -156,7 +198,7 @@ function calcQuote() {
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 voipFaxAmt = addVoipFax ? voipSeats * VOIP_FAX_RATE : 0;
const voipTotal = voipSeatsAmt + voipPhoneAmt + voipFaxAmt;
const MRR = userTotal + endpointTotal + adminFeeEffective + ztNetTotal + voipTotal;
@@ -204,6 +246,7 @@ function calcQuote() {
// 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.
@@ -272,11 +315,12 @@ function update() {
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)'
? `✓ 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) {
@@ -299,10 +343,11 @@ function update() {
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.style.display = '';
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`);
@@ -311,13 +356,14 @@ function update() {
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.style.display = '';
const epParts = [`${endpoints} × $35/endpoint`];
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>');
@@ -342,7 +388,7 @@ function update() {
// 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';
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)
@@ -371,10 +417,6 @@ function update() {
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);
@@ -408,12 +450,13 @@ function update() {
// 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);
if (!byol && users > 0) getEl('m365SaveAmt').textContent = fmt(users * m365BundleSavings);
// BYOL callouts
getEl('byolCalloutGreen').classList.toggle('hidden', byol);
getEl('byolCalloutRed').classList.toggle('hidden', !byol);
if (byol) getEl('byolRedSavings').textContent = fmt(users * 15);
getEl('userIncluded').classList.toggle('byol-mode', byol);
if (byol) getEl('byolRedSavings').textContent = fmt(users * m365BundleSavings);
// VoIP tier active state
['basic','standard','premium'].forEach(t => {
@@ -442,19 +485,19 @@ function update() {
// 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.`,
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 * 9)}/mo for ${users} user${users !== 1 ? 's' : ''}.`,
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 ($130/user) bundles the license and saves the client up to ${fmt(users * 15)}/mo vs retail Microsoft 365 pricing.`,
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 * 25)}/mo covers all ${endpoints} endpoint${endpoints !== 1 ? 's' : ''}. Fast, bare-metal recovery if the worst happens.`,
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({
@@ -469,28 +512,66 @@ function update() {
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;
_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-01': ['adminCovered'],
'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';
updateSectionSummaries();
// 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();
}
@@ -505,17 +586,36 @@ function toggleAllSections() {
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 = ''; }
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();
updateSectionSummaries(calcQuote());
updateToggleAllBtn();
}
function updateToggleAllBtn() {
const anyOpen = _allSecIds.some(id => document.getElementById(id)?.classList.contains('sec-open'));
const btn = document.getElementById('toggleAllBtn');
const collapseIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:5px;"><polyline points="6 9 12 15 18 9"/><polyline points="6 15 12 9 18 15"/></svg>';
const expandIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:5px;"><polyline points="6 15 12 9 18 15"/><polyline points="6 9 12 15 18 9"/></svg>';
if (btn) btn.innerHTML = anyOpen ? collapseIcon + 'Collapse All' : expandIcon + 'Expand All';
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) ─────────────────────────────
@@ -537,7 +637,7 @@ function stepCount(inputId, delta, event) {
// and text is non-empty; display:none otherwise.
// Called by update() and toggleSection().
function updateSectionSummaries(q) {
q = q || calcQuote();
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);
@@ -626,9 +726,9 @@ function updateVsComparison(q) {
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();
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');
@@ -647,18 +747,18 @@ function updateVsComparison(q) {
const toolsLabel = toolsMonthly <= TOOL_COST_MIN
? `min $${TOOL_COST_MIN}/mo`
: `~$${toolsMonthly}/mo`;
getEl('vs-footnote').textContent = `Based on ~$85K Ottawa IT salary (2024) + ${toolsLabel} tool licensing (M365, EDR, RMM, backup, SAT & more). No benefits, hiring, or turnover costs factored.`;
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 window._nudges[] and window._nudgeIndex.
// 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 = window._nudges || [];
const idx = window._nudgeIndex || 0;
const nudges = _nudges;
const idx = _nudgeIndex;
function applyNudge(suffix) {
const s = suffix || '';
@@ -684,9 +784,8 @@ function renderNudge() {
// 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;
if (!_nudges.length) return;
_nudgeIndex = (_nudgeIndex + dir + _nudges.length) % _nudges.length;
renderNudge();
}
@@ -696,11 +795,10 @@ function cycleNudge(dir) {
// 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;
if (_nudgeTimer) clearInterval(_nudgeTimer);
_nudgeTimer = setInterval(() => {
if (_nudges.length > 1) {
_nudgeIndex = (_nudgeIndex + 1) % _nudges.length;
renderNudge();
}
}, 30000);
@@ -728,10 +826,10 @@ function updateSavings(q) {
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 = '';
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.style.color = 'var(--amber)';
comparator.classList.add('savings-amber');
}
}
@@ -780,7 +878,7 @@ function saveState() {
onboardingManual: document.getElementById('oneTimeFee')?.dataset.manual === '1',
};
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
} catch(e) {}
} catch(e) { console.warn('saveState: failed to persist quote', e); }
}
let _saveTimer;
function debouncedSave() {
@@ -828,10 +926,10 @@ function restoreState() {
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 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');
}
@@ -864,33 +962,44 @@ function printInvoice() {
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)';
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 (+$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.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 Email Security (+${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':''} × $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);
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':''} × $120/mo`, fmt(q.serverBase));
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} × $25/mo)`, '', fmt(q.ztNetSeats), true);
if (q.ztNetRouters > 0) row(`↳ HaaS Routers (${q.ztRouters} × $100/mo)`, '', fmt(q.ztNetRouters), true);
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 (+$15/seat)', '', fmt(q.voipPhoneAmt), true);
if (q.voipFaxAmt > 0) row('↳ Virtual Fax (+$10/mo)', '', fmt(q.voipFaxAmt), true);
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);
}
row('Site Admin Fee', 'Tenant, network, documentation & vendor management', fmt(q.adminFeeNet));
const itemsHTML = rows.map(r => `
<tr${r.sub?' class="sub"':''}>
@@ -899,6 +1008,29 @@ function printInvoice() {
<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 Email Security', 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) {
@@ -906,8 +1038,12 @@ function printInvoice() {
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 (13%)</td><td>+${fmt(q.hstAmt)}/mo</td></tr>`;
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) {
@@ -959,6 +1095,15 @@ body{font-family:'Poppins',Arial,sans-serif;font-size:13px;color:#1a1816;backgro
.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}
@@ -982,13 +1127,16 @@ body{font-family:'Poppins',Arial,sans-serif;font-size:13px;color:#1a1816;backgro
<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 13% applies on invoice unless already included. MRR is billed monthly in advance.</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>
@@ -1149,8 +1297,10 @@ async function initQuote() {
quoteRef = `SVS-${dateStr}-${String(Math.floor(Math.random()*9000)+1000)}`;
localStorage.setItem('svs-msp-quote-ref', quoteRef);
}
document.getElementById('quoteRef').textContent = quoteRef;
document.getElementById('headerDate').textContent = `${month} ${year}`;
const quoteRefEl = document.getElementById('quoteRef');
if (quoteRefEl) quoteRefEl.textContent = quoteRef;
const headerDateEl = document.getElementById('headerDate');
if (headerDateEl) headerDateEl.textContent = `${month} ${year}`;
restoreState();
update();
}
@@ -1158,6 +1308,23 @@ async function initQuote() {
initTheme();
initQuote();
// ── MOBILE SIDEBAR SYNC CONTRACT ──────────────────────────────────────
// Every stateful sidebar element has a mirror ID with _m suffix.
// The update() wrapper below syncs _m elements after each call to _origUpdate().
//
// WHEN ADDING A NEW SIDEBAR ELEMENT:
// 1. Add the desktop element with its ID (e.g. #my-element)
// 2. Add the mobile duplicate in #mobilePanelContent with ID #my-element_m
// 3. Add the appropriate sync call in the update() wrapper:
// syncEl(id) — copies innerHTML (text/HTML values)
// syncClass(id) — copies className (.hidden toggling via classList)
// syncStyle(id) — copies style.cssText (legacy inline display — avoid for new elements)
// syncChecked(id) — copies .checked state (checkboxes)
//
// NEVER DOM-move the real .sidebar into the panel.
// The duplicate HTML is intentional — moving breaks desktop layout on resize.
// ──────────────────────────────────────────────────────────────────────
// ── MOBILE QUOTE PANEL IIFE ──────────────────────────────────────
// Encapsulates all mobile panel logic to avoid polluting global scope.
// ARCHITECTURE:
@@ -1212,6 +1379,11 @@ initQuote();
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;
}
// ── UPDATE WRAPPER ─────────────────────────────────────────────
// Wraps the global update() to also sync the mobile panel.
@@ -1276,9 +1448,12 @@ initQuote();
syncClass('vs-5man-save');
syncClass('vs-5man-save-lbl');
syncClass('nudgeBanner');
syncClass('adminWaivedSavings');
syncEl('adminWaivedAmt');
syncStyle('sl-users-sub');
syncStyle('sl-endpoints-sub');
syncStyle('perUserRow');
syncChecked('hstToggle');
// Pill MRR — show effective MRR with label
var mrr = document.getElementById('mrrDisplay');
var pill = document.getElementById('mobilePillMrr');