Files
svsmspcalc/quote-render.js
2026-03-16 01:42:17 -04:00

805 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function(global) {
'use strict';
let nudges = [];
let nudgeIndex = 0;
let nudgeTimer;
function fmt(n) {
return '$' + Math.round(n).toLocaleString('en-US');
}
function getEl(id) {
return document.getElementById(id);
}
/* ── Animated number counter ─────────────────────────────────
Smoothly interpolates a displayed dollar value from old → new.
Targets both the desktop element and its _m mobile clone.
Duration: 350ms ease-out via requestAnimationFrame.
──────────────────────────────────────────────────────────── */
var _animFrames = {};
function animateValue(elId, newVal, formatFn) {
var el = document.getElementById(elId);
if (!el) return;
var elM = document.getElementById(elId + '_m');
var raw = (el.textContent || '').replace(/[^0-9.\-]/g, '');
var from = parseFloat(raw) || 0;
var to = parseFloat(newVal) || 0;
if (from === to) {
el.textContent = formatFn(to);
if (elM) elM.textContent = formatFn(to);
return;
}
if (_animFrames[elId]) cancelAnimationFrame(_animFrames[elId]);
var duration = 350;
var start = null;
function step(ts) {
if (!start) start = ts;
var progress = Math.min((ts - start) / duration, 1);
var eased = 1 - Math.pow(1 - progress, 3);
var current = from + (to - from) * eased;
el.textContent = formatFn(current);
if (elM) elM.textContent = formatFn(current);
if (progress < 1) {
_animFrames[elId] = requestAnimationFrame(step);
} else {
delete _animFrames[elId];
}
}
_animFrames[elId] = requestAnimationFrame(step);
}
function getQuote(q) {
return q || (typeof global.calcQuote === 'function' ? global.calcQuote() : null);
}
function getPricing() {
return typeof global.getPricingConfig === 'function' ? global.getPricingConfig() : global;
}
function getM365BundleSavings(options, pricing) {
if (options && typeof options.m365BundleSavings === 'number') {
return options.m365BundleSavings;
}
// Use the higher M365 rate (m2m) for maximum savings comparison
const bestRate = Math.max(pricing.RATE_M365_M2M || 0, pricing.RATE_M365 || 0);
return Math.max(0, bestRate - (pricing.RATE_BYOL || 0));
}
function renderQuoteUi(q, options) {
if (!q) return;
const pricing = getPricing();
const m365BundleSavings = getM365BundleSavings(options, pricing);
const onboardingWaived = !!options?.onboardingWaived;
const onboardingHalfOff = !!options?.onboardingHalfOff;
const onboardingWouldBe = typeof options?.onboardingWouldBe === 'number'
? options.onboardingWouldBe
: Math.round((q.MRR || 0) / 2);
const {
users,
voipTier,
byol,
addPWM,
ztActive,
adminWaived,
clientName,
contractTerm,
baseSubtotal,
siteAdminBase,
admin1PWM,
adminFeeNet,
discountAmt
} = q;
if (adminWaived) {
getEl('adminFeeDisplay').innerHTML =
`<span class="admin-fee-strike">${fmt(adminFeeNet)}/mo</span> <span class="admin-fee-waived-badge">WAIVED</span>`;
} else {
getEl('adminFeeDisplay').textContent = fmt(adminFeeNet) + '/mo';
}
const fillPct = Math.min(100, Math.max(0, (baseSubtotal / pricing.ADMIN_FEE_MINIMUM) * 100));
getEl('floorBar').style.width = fillPct + '%';
const atFloor = baseSubtotal >= pricing.ADMIN_FEE_MINIMUM;
getEl('floorBar').style.background = atFloor ? 'var(--green)' : 'var(--accent)';
getEl('floorNote').textContent = atFloor
? `✓ Oversight fee is now at its minimum level (${fmt(pricing.ADMIN_FEE_FLOOR)}/mo)`
: `Add ${fmt(Math.max(0, pricing.ADMIN_FEE_MINIMUM - baseSubtotal))} more in managed services and this fee will reduce further`;
getEl('fb-base').textContent = fmt(siteAdminBase);
getEl('fb-zt-row').classList.toggle('hidden', !ztActive);
getEl('fb-zt').textContent = '+' + fmt(pricing.ADMIN_FEE_ZT);
getEl('fb-pwm-row').classList.toggle('hidden', !addPWM);
getEl('fb-pwm').textContent = '+' + fmt(admin1PWM);
if (adminWaived) {
getEl('fb-total').innerHTML =
`<span class="admin-fee-strike">${fmt(adminFeeNet)}</span> <span class="admin-fee-waived-badge">WAIVED</span>`;
} else {
getEl('fb-total').textContent = fmt(adminFeeNet);
}
const adminWaivedSavingsEl = document.getElementById('adminWaivedSavings');
if (adminWaivedSavingsEl) {
adminWaivedSavingsEl.classList.toggle('hidden', !adminWaived || adminFeeNet === 0);
const awAmt = document.getElementById('adminWaivedAmt');
if (awAmt) awAmt.textContent = fmt(adminFeeNet);
}
getEl('byolCalloutGreen').classList.toggle('hidden', byol);
getEl('byolCalloutRed').classList.toggle('hidden', !byol);
getEl('userIncluded').classList.toggle('byol-mode', byol);
getEl('userBundleStrip')?.classList.toggle('byol-disabled', byol);
// M365 term-aware pricing display
const isAnnualTerm = contractTerm === '12mo' || contractTerm === '24mo';
const m365PriceM2m = getEl('m365PriceM2m');
const m365PriceAnnual = getEl('m365PriceAnnual');
if (m365PriceM2m && m365PriceAnnual) {
if (isAnnualTerm) {
m365PriceM2m.className = 'm365-price-grey';
m365PriceM2m.textContent = '$' + pricing.RATE_M365_M2M;
m365PriceAnnual.classList.remove('hidden');
m365PriceAnnual.textContent = '$' + pricing.RATE_M365;
} else {
m365PriceM2m.className = '';
m365PriceM2m.textContent = '$' + pricing.RATE_M365_M2M;
m365PriceAnnual.classList.add('hidden');
}
}
const retailRate = isAnnualTerm ? pricing.M365_RETAIL_ANNUAL : pricing.M365_RETAIL_MONTHLY;
const svsRate = isAnnualTerm ? pricing.RATE_M365 : pricing.RATE_M365_M2M;
const officePortion = svsRate - pricing.RATE_BYOL;
const officeSavings = retailRate - officePortion;
const savingsLine = getEl('m365SavingsLine');
if (savingsLine) savingsLine.classList.add('hidden');
const m365CalloutText = getEl('m365CalloutText');
if (m365CalloutText && !byol) {
const retailLabel = isAnnualTerm ? '$' + retailRate + '/user annual' : '$' + retailRate + '/user monthly';
m365CalloutText.textContent = 'Recommended bundle \u2014 M365 Business Premium is built into this seat, saving $' + officeSavings + '/user vs ' + retailLabel + ' retail licensing';
}
if (byol) getEl('byolRedSavings').textContent = fmt(users * m365BundleSavings);
['basic', 'standard', 'premium'].forEach(tier => {
const seg = getEl('seg-' + tier);
if (seg) seg.classList.toggle('active', tier === voipTier);
});
['m2m', '12mo', '24mo'].forEach(term => {
const seg = getEl('seg-term-' + term);
if (seg) seg.classList.toggle('active', term === contractTerm);
});
const savingsRow = document.getElementById('qsSavingsDisplay');
const savingsCopy = document.getElementById('qsSavingsCopy');
const firstYearRow = document.getElementById('qsFirstYearDisplay');
const firstYearCopy = document.getElementById('qsFirstYearCopy');
const termLabel = contractTerm === '12mo' ? '12-Month' : contractTerm === '24mo' ? '24-Month' : 'Month-to-Month';
const annualizedTermSavings = discountAmt > 0 ? discountAmt * 12 : 0;
const termOnboardingValue = onboardingWaived ? onboardingWouldBe
: onboardingHalfOff ? Math.round(onboardingWouldBe / 2) : 0;
const firstYearValue = annualizedTermSavings + termOnboardingValue;
if (savingsRow && savingsCopy) {
if (discountAmt > 0) {
savingsCopy.innerHTML = `${termLabel} savings: <strong>${fmt(discountAmt)}</strong>/mo vs. month-to-month`;
savingsRow.classList.remove('hidden');
} else {
savingsRow.classList.add('hidden');
}
}
if (firstYearRow && firstYearCopy) {
if (firstYearValue > 0) {
let suffix = ' in year-one value';
if (annualizedTermSavings > 0 && onboardingWaived) {
suffix = ' from annualized term savings and complimentary onboarding';
} else if (annualizedTermSavings > 0 && onboardingHalfOff) {
suffix = ' from annualized term savings and 50% off onboarding';
} else if (annualizedTermSavings > 0) {
suffix = ' from annualized term savings';
} else if (onboardingWaived) {
suffix = ' from complimentary onboarding';
} else if (onboardingHalfOff) {
suffix = ' from 50% off onboarding';
}
firstYearCopy.innerHTML = `Year-one value unlocked: <strong>${fmt(firstYearValue)}</strong>${suffix}`;
firstYearRow.classList.remove('hidden');
} else {
firstYearRow.classList.add('hidden');
}
}
document.querySelectorAll('.addon-preview-pill[data-addon]').forEach(pill => {
const cb = document.getElementById(pill.dataset.addon);
pill.classList.toggle('active', cb?.checked || false);
});
}
function renderSidebar(q, options) {
if (!q) return;
const pricing = getPricing();
const {
users,
endpoints,
servers,
voipSeats,
byol,
addPWM,
addINKY,
addExtHours,
addZT,
addUSB,
addBMB,
ztActive,
adminWaived,
contractTerm,
hstEnabled,
baseUserRate,
userPWM,
userINKY,
userExt,
userZT,
userTotal,
serverBase,
endpointUSB,
endpointBMB,
endpointTotal,
ztNetTotal,
voipTotal,
siteAdminBase,
admin1PWM,
adminFeeNet,
clientName,
MRR,
discountPct,
discountAmt,
effectiveMrr,
effectiveAnnual,
hstAmt,
mrrWithHst,
perUserServices,
perUserSiteOvhd
} = q;
const onboardingWaived = !!options?.onboardingWaived;
const onboardingHalfOff = !!options?.onboardingHalfOff;
const onboardingWouldBe = typeof options?.onboardingWouldBe === 'number'
? options.onboardingWouldBe
: Math.round(q.MRR / 2);
const oneTimeFee = typeof options?.oneTimeFee === 'number'
? options.oneTimeFee
: (parseFloat(q.oneTimeFee) || 0);
const annualizedTermSavings = discountPct > 0 ? discountAmt * 12 : 0;
const onboardingValueUnlocked = onboardingWaived ? onboardingWouldBe
: onboardingHalfOff ? Math.round(onboardingWouldBe / 2) : 0;
const isAnnualTermSb = contractTerm === '12mo' || contractTerm === '24mo';
const retailRateSb = isAnnualTermSb ? (pricing.M365_RETAIL_ANNUAL || 30) : (pricing.M365_RETAIL_MONTHLY || 35);
const svsRateSb = isAnnualTermSb ? pricing.RATE_M365 : (pricing.RATE_M365_M2M || pricing.RATE_M365);
const officePortionSb = svsRateSb - pricing.RATE_BYOL;
const officeSavingsSb = Math.max(0, retailRateSb - officePortionSb);
const m365AnnualValue = (!byol && users > 0) ? (users * officeSavingsSb * 12) : 0;
const byolOpportunityValue = (byol && users > 0) ? (users * getM365BundleSavings(options, pricing) * 12) : 0;
const adminIncludedValue = adminWaived ? (adminFeeNet * 12) : 0;
const totalValueUnlocked = m365AnnualValue + annualizedTermSavings + onboardingValueUnlocked + adminIncludedValue;
const firstInvoiceTotal = effectiveMrr + (hstEnabled ? hstAmt : 0) + oneTimeFee;
const sidebarClientNameEl = getEl('sidebarFocusClientName');
if (sidebarClientNameEl) {
sidebarClientNameEl.textContent = clientName && clientName.trim() ? clientName.trim() : 'Client Name';
}
const show = (id, val) => {
const el = getEl(id);
if (!el) return;
el.classList.toggle('hidden', !val);
};
const renderSubRows = (rows) => rows.map(row => {
if (!row.addon) {
return `<div class="sl-sub-row sl-sub-row-base"><span class="sl-sub-copy">${row.copy}</span></div>`;
}
return `<div class="sl-sub-row sl-sub-row-addon"><span class="sl-sub-copy">${row.copy}</span><span class="sl-sub-val">${row.value}</span></div>`;
}).join('');
show('sl-users', users > 0);
getEl('sl-users-sub')?.classList.toggle('hidden', users === 0);
if (users > 0) {
animateValue('sl-users-val', userTotal, fmt);
const sub = getEl('sl-users-sub');
sub.classList.remove('hidden');
const subRows = [{ copy: `${users} × ${fmt(baseUserRate)}/user (${byol ? 'BYOL' : 'M365 Incl.'})` }];
if (addExtHours) subRows.push({ copy: 'Extended Help Desk Hours', value: `${fmt(userExt)}/mo`, addon: true });
if (addPWM) subRows.push({ copy: '1Password', value: `${fmt(userPWM)}/mo`, addon: true });
if (addINKY) subRows.push({ copy: 'INKY Pro Upgrade', value: `${fmt(userINKY)}/mo`, addon: true });
if (addZT) subRows.push({ copy: 'Zero Trust', value: `${fmt(userZT)}/mo`, addon: true });
sub.innerHTML = renderSubRows(subRows);
}
show('sl-endpoints', endpoints > 0);
getEl('sl-endpoints-sub')?.classList.toggle('hidden', endpoints === 0);
if (endpoints > 0) {
const epOnly = endpointTotal - serverBase;
animateValue('sl-endpoints-val', epOnly, fmt);
const sub = getEl('sl-endpoints-sub');
sub.classList.remove('hidden');
const epRows = [{ copy: `${endpoints} × ${fmt(pricing.RATE_ENDPOINT)}/endpoint` }];
if (addBMB) epRows.push({ copy: 'Bare Metal Backup', value: `${fmt(endpointBMB)}/mo`, addon: true });
if (addUSB) epRows.push({ copy: 'USB Blocking', value: `${fmt(endpointUSB)}/mo`, addon: true });
sub.innerHTML = renderSubRows(epRows);
}
show('sl-servers', servers > 0);
if (servers > 0) animateValue('sl-servers-val', serverBase, fmt);
show('sl-zt', ztNetTotal > 0);
if (ztNetTotal > 0) animateValue('sl-zt-val', ztNetTotal, fmt);
show('sl-voip', voipTotal > 0);
if (voipTotal > 0) animateValue('sl-voip-val', voipTotal, fmt);
const slAdminEl = getEl('sl-admin');
const slAdminValEl = getEl('sl-admin-val');
const slAdminSubEl = getEl('sl-admin-sub');
if (adminWaived) {
slAdminEl?.classList.add('sl-admin-waived');
if (slAdminValEl) {
slAdminValEl.innerHTML =
`<span class="otf-amt">${fmt(adminFeeNet)}</span> <span class="otf-waived-label">WAIVED</span>`;
}
} else {
slAdminEl?.classList.remove('sl-admin-waived');
if (slAdminValEl) slAdminValEl.textContent = fmt(adminFeeNet);
}
if (slAdminSubEl) {
const adminRows = [{ copy: `Base ${fmt(siteAdminBase)}/mo` }];
if (ztActive) adminRows.push({ copy: 'Zero Trust Supplement', value: `${fmt(pricing.ADMIN_FEE_ZT)}/mo`, addon: true });
if (addPWM && admin1PWM > 0) adminRows.push({ copy: '1Password Admin', value: `${fmt(admin1PWM)}/mo`, addon: true });
slAdminSubEl.classList.remove('hidden');
slAdminSubEl.innerHTML = renderSubRows(adminRows);
}
animateValue('mrrDisplay', effectiveMrr, fmt);
const monthlyTotalEl = getEl('sl-monthly-total-val');
if (monthlyTotalEl) monthlyTotalEl.innerHTML = fmt(effectiveMrr) + '<span class="suffix-mo">/mo</span>';
getEl('annualDisplay').textContent = fmt(effectiveAnnual);
getEl('perUserRow').classList.toggle('hidden', users === 0);
if (users > 0) getEl('perUserDisplay').textContent = fmt(effectiveMrr / users) + '/user';
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 discDetailEl = getEl('sl-discount-detail');
if (discDetailEl) {
discDetailEl.textContent = `(${termLabel}, ${Math.round(discountPct * 100)}% off)`;
}
const discEl = getEl('sl-discount-val');
if (discEl) {
discEl.textContent = `${fmt(discountAmt)}/mo`;
}
}
}
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';
const firstMriEl = getEl('sl-first-mri-val');
if (firstMriEl) firstMriEl.textContent = fmt(effectiveMrr);
show('sl-first-hst-row', hstEnabled);
const firstHstEl = getEl('sl-first-hst-val');
if (firstHstEl && hstEnabled) firstHstEl.textContent = fmt(hstAmt);
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';
}
const otfRow = getEl('sl-otf-row');
const otfValEl = getEl('sl-otf-val');
if (otfRow) {
if (onboardingWaived && onboardingWouldBe > 0) {
otfRow.classList.remove('hidden');
otfRow.classList.add('sl-otf-waived');
if (otfValEl) {
otfValEl.innerHTML =
'<span class="otf-amt-strike">' + fmt(onboardingWouldBe) + '</span> <span class="otf-waived-label">COMPLIMENTARY</span>';
}
} else if (onboardingHalfOff && onboardingWouldBe > 0) {
otfRow.classList.remove('hidden');
otfRow.classList.add('sl-otf-waived');
if (otfValEl) {
otfValEl.innerHTML =
'<span class="otf-waived-label">50% OFF</span> ' +
'<span class="otf-amt-strike">' + fmt(onboardingWouldBe) + '</span> ' +
'<span class="otf-amt">' + fmt(oneTimeFee) + '</span>';
}
} else if (oneTimeFee > 0) {
otfRow.classList.remove('hidden', 'sl-otf-waived');
if (otfValEl) otfValEl.textContent = fmt(oneTimeFee) + ' (one-time)';
} else {
otfRow.classList.add('hidden');
otfRow.classList.remove('sl-otf-waived');
}
}
const firstTotalEl = getEl('sl-first-total-val');
if (firstTotalEl) firstTotalEl.textContent = fmt(firstInvoiceTotal);
show('sl-value-m365-row', m365AnnualValue > 0);
const valueM365El = getEl('sl-value-m365-val');
if (valueM365El && m365AnnualValue > 0) valueM365El.textContent = fmt(m365AnnualValue) + '/yr';
show('sl-value-term-row', annualizedTermSavings > 0);
const valueTermEl = getEl('sl-value-term-val');
if (valueTermEl && annualizedTermSavings > 0) valueTermEl.textContent = fmt(annualizedTermSavings) + '/yr';
show('sl-value-onboarding-row', onboardingValueUnlocked > 0);
const valueOnboardingEl = getEl('sl-value-onboarding-val');
if (valueOnboardingEl && onboardingValueUnlocked > 0) valueOnboardingEl.textContent = fmt(onboardingValueUnlocked);
const valueOnboardingLabel = getEl('sl-value-onboarding-label');
if (valueOnboardingLabel) valueOnboardingLabel.textContent = onboardingWaived ? 'Complimentary Onboarding' : 'Onboarding Savings (50% off)';
show('sl-value-admin-row', adminIncludedValue > 0);
const valueAdminEl = getEl('sl-value-admin-val');
if (valueAdminEl && adminIncludedValue > 0) valueAdminEl.textContent = fmt(adminIncludedValue) + '/yr';
show('sl-value-byol-row', byolOpportunityValue > 0);
const valueByolEl = getEl('sl-value-byol-val');
if (valueByolEl && byolOpportunityValue > 0) valueByolEl.textContent = fmt(byolOpportunityValue) + '/yr';
show('sl-value-total-row', totalValueUnlocked > 0);
const valueTotalEl = getEl('sl-value-total-val');
if (valueTotalEl && totalValueUnlocked > 0) valueTotalEl.textContent = fmt(totalValueUnlocked) + '/yr';
const puBreakdown = getEl('perUserBreakdown');
if (puBreakdown) {
puBreakdown.classList.toggle('hidden', users === 0);
if (users > 0) {
puBreakdown.textContent = `Includes ${fmt(perUserServices)} in user services + ${fmt(perUserSiteOvhd)} in shared site overhead`;
}
}
}
function buildNudges(q, options) {
if (!q) return [];
const pricing = getPricing();
const m365BundleSavings = getM365BundleSavings(options, pricing);
const {
users,
endpoints,
servers,
voipSeats,
voipTier,
contractTerm,
MRR,
byol,
addPWM,
addExtHours,
addZT,
addBMB
} = q;
const nextNudges = [];
if (!addZT && users > 0) {
nextNudges.push({
text: `Zero Trust Deny By Default adds enterprise-grade access control — ${fmt(users * pricing.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) {
nextNudges.push({
text: `1Password Management — one vault for every credential, enforce strong passwords, revoke access instantly when staff leave. Only ${fmt(users * pricing.ADDON_1PASSWORD)}/mo for ${users} user${users !== 1 ? 's' : ''}.`,
color: 'green'
});
}
if (contractTerm === 'm2m' && MRR > 0) {
const onboardingWouldBe = typeof options?.onboardingWouldBe === 'number'
? options.onboardingWouldBe
: Math.round(MRR / 2);
nextNudges.push({
text: `A 12-month term saves 3% on MRR and 50% off onboarding. The 24-month option saves ${fmt(MRR * pricing.DISCOUNT_24MO)}/mo (5%) and includes complimentary onboarding — saving an estimated ${fmt(onboardingWouldBe)}.`,
color: 'green'
});
}
if (byol && users > 0) {
const m365RateForTerm = contractTerm === 'm2m' ? (pricing.RATE_M365_M2M || pricing.RATE_M365) : pricing.RATE_M365;
nextNudges.push({
text: `BYOL keeps the client's existing licensing in place. Switching to M365 Included (${fmt(m365RateForTerm)}/user) bundles licensing at well below retail — saving up to ${fmt(users * m365BundleSavings)}/mo across ${users} user${users !== 1 ? 's' : ''}.`,
color: 'green'
});
}
if (q.ztActive) {
nextNudges.push({
text: `Zero Trust is active — a ${fmt(pricing.ADMIN_FEE_ZT)}/mo site admin supplement applies to cover additional tenant management, policy enforcement, and network oversight.`,
color: 'amber'
});
}
if (endpoints > 0 && !addBMB) {
nextNudges.push({
text: `Bare Metal Backup adds the recovery layer to this endpoint package — ${fmt(endpoints * pricing.ADDON_BARE_METAL_BACKUP)}/mo protects all ${endpoints} endpoint${endpoints !== 1 ? 's' : ''} with image-level restore after ransomware, hardware failure, or a full rebuild event.`,
color: 'amber'
});
}
if (voipSeats > 0 && voipTier === 'basic') {
nextNudges.push({
text: `Standard tier adds HD video, call analytics, and advanced call routing — only ${fmt(voipSeats * (pricing.VOIP_RATE_STANDARD - pricing.VOIP_RATE_BASIC))}/mo more for ${voipSeats} seat${voipSeats !== 1 ? 's' : ''}. Most clients upgrade within 6 months.`,
color: 'green'
});
}
if (servers > 0 && !addBMB) {
nextNudges.push({
text: `Servers are in scope but Bare Metal Backup is not selected — adding BMB closes the DR gap by pairing endpoint protection with full-system recovery across workstations and server workloads.`,
color: 'amber'
});
}
if (users > 0 && endpoints > 0 && endpoints > users * 1.5) {
nextNudges.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'
});
}
if (users > 0 && endpoints === 0) {
nextNudges.push({
text: `${users} user${users !== 1 ? 's' : ''} configured but no endpoints — most environments need workstation management alongside user services. Add endpoints to close the coverage gap.`,
color: 'amber'
});
}
if (voipSeats > 0 && users > 0 && voipSeats !== users) {
const diff = Math.abs(voipSeats - users);
const direction = voipSeats > users ? 'more VoIP seats than users' : 'fewer VoIP seats than users';
nextNudges.push({
text: `${voipSeats} VoIP seat${voipSeats !== 1 ? 's' : ''} vs ${users} user${users !== 1 ? 's' : ''}${diff} ${direction}. Verify whether every user needs a phone line, or if shared/common-area lines explain the difference.`,
color: 'amber'
});
}
if (MRR > 0 && !q.adminWaived && q.adminFeeNet > MRR * 0.25) {
nextNudges.push({
text: `Site admin is ${fmt(q.adminFeeNet)}/mo — over 25% of the ${fmt(MRR)} MRR. Adding more managed services will reduce this ratio as the admin fee floors out at ${fmt(pricing.ADMIN_FEE_FLOOR)}/mo.`,
color: 'amber'
});
}
if (!addExtHours && users > 0) {
nextNudges.push({
text: `Extended Help Desk Hours adds after-hours and weekend support coverage — only ${fmt(users * pricing.ADDON_EXT_HOURS)}/mo for ${users} user${users !== 1 ? 's' : ''}. Ideal for clients with shift workers or off-hours operations.`,
color: 'green'
});
}
return nextNudges;
}
function setNudges(nextNudges) {
nudges = Array.isArray(nextNudges) ? nextNudges : [];
if (!nudges.length) {
nudgeIndex = 0;
return;
}
if (nudgeIndex >= nudges.length) {
nudgeIndex = 0;
}
}
function renderNudge() {
const currentIndex = nudges.length ? 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[currentIndex];
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 ? `${currentIndex + 1}/${nudges.length}` : '';
const btns = banner.querySelectorAll('button');
btns.forEach(btn => {
btn.style.display = nudges.length > 1 ? 'flex' : 'none';
});
}
applyNudge('');
applyNudge('_m');
}
function cycleNudge(dir) {
if (!nudges.length) return;
// Crossfade: fade out, swap, fade in
var banners = [document.getElementById('nudgeBanner'), document.getElementById('nudgeBanner_m')];
banners.forEach(function(b) { if (b) b.classList.add('nudge-fading'); });
setTimeout(function() {
nudgeIndex = (nudgeIndex + dir + nudges.length) % nudges.length;
renderNudge();
banners.forEach(function(b) { if (b) b.classList.remove('nudge-fading'); });
}, 180);
}
function startNudgeRotation() {
if (nudgeTimer) clearInterval(nudgeTimer);
nudgeTimer = setInterval(() => {
if (nudges.length > 1) {
cycleNudge(1);
}
}, 30000);
}
function updateSectionSummaries(q) {
q = getQuote(q);
if (!q) return;
const collapsed = id => !document.getElementById(id)?.classList.contains('sec-open');
const setSummary = (id, text, numericVal) => {
const el = document.getElementById(id);
if (!el) return;
const secId = id.replace('-summary', '').replace('sec0', 'sec-0');
const show = collapsed(secId) && !!text;
if (numericVal !== undefined && show) {
animateValue(id, numericVal, function(v) { return fmt(v) + '/mo'; });
} else {
el.textContent = text || '';
}
el.hidden = !show;
el.style.display = show ? 'flex' : 'none';
};
setSummary('sec01-summary', q.adminWaived ? 'WAIVED' : fmt(q.adminFeeNet) + '/mo');
setSummary('sec02-summary', q.users > 0 ? `${fmt(q.userTotal)}/mo` : '', q.users > 0 ? q.userTotal : undefined);
setSummary('sec03-summary', q.endpoints > 0 ? `${fmt(q.endpointTotal - q.serverBase)}/mo` : '', q.endpoints > 0 ? q.endpointTotal - q.serverBase : undefined);
setSummary('sec04-summary', q.servers > 0 ? `${fmt(q.serverBase)}/mo` : '', q.servers > 0 ? q.serverBase : undefined);
setSummary('sec05-summary', q.ztNetTotal > 0 ? `${fmt(q.ztNetTotal)}/mo` : '', q.ztNetTotal > 0 ? q.ztNetTotal : undefined);
setSummary('sec06-summary', q.voipSeats > 0 ? `${fmt(q.voipTotal)}/mo` : '', q.voipSeats > 0 ? q.voipTotal : undefined);
}
function updateVsComparison(q) {
const vsEl = document.getElementById('vsComparison');
if (!vsEl) return;
q = getQuote(q);
if (!q) return;
const pricing = getPricing();
const { users, endpoints, effectiveAnnual } = q;
if (users < 1 && endpoints < 1) {
vsEl.classList.add('hidden');
return;
}
vsEl.classList.remove('hidden');
const toolsMonthly = Math.max(
pricing.TOOL_COST_MIN,
(users * pricing.TOOL_COST_PER_USER) + (endpoints * pricing.TOOL_COST_PER_ENDPOINT)
);
const toolsAnnual = toolsMonthly * 12;
const cost1 = pricing.IT_SALARY_1 + toolsAnnual;
const cost5 = pricing.IT_SALARY_5 + toolsAnnual;
const save1 = cost1 - effectiveAnnual;
const save5 = cost5 - effectiveAnnual;
const fmtK = n => n >= 0 ? '$' + Math.round(n / 1000) + 'K/yr' : '$' + Math.round(Math.abs(n) / 1000) + 'K/yr';
getEl('vs-svs-annual').textContent = '$' + Math.round(effectiveAnnual / 1000) + 'K/yr';
getEl('vs-1man-cost').textContent = '$' + Math.round(cost1 / 1000) + 'K/yr';
getEl('vs-5man-cost').textContent = '$' + Math.round(cost5 / 1000) + 'K/yr';
const applyVsRow = (rowId, saveId, labelId, saving) => {
const row = getEl(rowId);
const val = getEl(saveId);
const lbl = getEl(labelId);
if (!row || !val || !lbl) return;
val.textContent = fmtK(saving);
row.classList.remove('vs-save-green', 'vs-save-amber');
val.classList.remove('vs-val-green', 'vs-val-amber');
lbl.classList.remove('vs-val-green', 'vs-val-amber');
if (saving > 0) {
row.classList.add('vs-save-green');
val.classList.add('vs-val-green');
lbl.classList.add('vs-val-green');
lbl.textContent = 'EST. SAVINGS';
} else {
row.classList.add('vs-save-amber');
val.classList.add('vs-val-amber');
lbl.classList.add('vs-val-amber');
lbl.textContent = 'DIFFERENCE';
}
};
applyVsRow('vs-1man-save-row', 'vs-1man-save', 'vs-1man-save-lbl', save1);
applyVsRow('vs-5man-save-row', 'vs-5man-save', 'vs-5man-save-lbl', save5);
const toolsLabel = toolsMonthly <= pricing.TOOL_COST_MIN
? `min $${pricing.TOOL_COST_MIN}/mo`
: `~$${toolsMonthly}/mo`;
getEl('vs-footnote').textContent =
`Based on ~$${Math.round(pricing.IT_SALARY_1 / 1000)}K Ottawa IT salary + ${toolsLabel} tool licensing (M365, EDR, RMM, backup, SAT & more). Excludes benefits, recruiting, PTO coverage, and turnover costs.`;
}
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 = getQuote(q);
if (!q) return;
const { voipTotal, voipSeats } = q;
if (voipSeats < 1) {
comparator.classList.add('hidden');
if (prompt) prompt.style.display = 'none';
return;
}
if (prompt) prompt.style.display = bill === 0 ? '' : 'none';
if (bill === 0) {
comparator.classList.add('hidden');
return;
}
comparator.classList.remove('hidden');
const saving = bill - voipTotal;
if (saving > 0) {
comparator.textContent = `✓ Switching to SVS VoIP saves ~${fmt(saving)}/mo (${fmt(saving * 12)}/yr) vs your current bill of ${fmt(bill)}/mo`;
comparator.classList.remove('savings-amber');
} else {
comparator.textContent = `Your current bill (${fmt(bill)}/mo) is lower than this VoIP quote (${fmt(voipTotal)}/mo) — consider reviewing the tier or seat count.`;
comparator.classList.add('savings-amber');
}
}
const api = {
renderQuoteUi,
renderSidebar,
buildNudges,
setNudges,
updateSectionSummaries,
updateVsComparison,
renderNudge,
cycleNudge,
startNudgeRotation,
updateSavings
};
global.SVSQuoteRender = api;
global.updateSectionSummaries = updateSectionSummaries;
global.updateVsComparison = updateVsComparison;
global.renderNudge = renderNudge;
global.cycleNudge = cycleNudge;
global.startNudgeRotation = startNudgeRotation;
global.updateSavings = updateSavings;
startNudgeRotation();
})(window);