Before Theme Change
This commit is contained in:
@@ -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 ? '✓' : '✕'}</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 · 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');
|
||||
|
||||
Reference in New Issue
Block a user