805 lines
32 KiB
JavaScript
805 lines
32 KiB
JavaScript
(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);
|