408 lines
16 KiB
JavaScript
408 lines
16 KiB
JavaScript
// Pricing defaults and JSON loading live in quote-pricing.js.
|
|
// This file intentionally consumes the pricing globals exposed there
|
|
// so Phase 1 can stay low-risk and preserve the current runtime API.
|
|
|
|
// Rendering helpers and nudge state live in quote-render.js.
|
|
// Persistence, export/print, theme, and mobile sync live in dedicated
|
|
// modules so this file can stay focused on quote calculation orchestration.
|
|
|
|
// --- CALC ---
|
|
// ── calcQuote() ─────────────────────────────────────────────────
|
|
// Compatibility wrapper around the pure Phase 2 quote engine.
|
|
// Reads live form state from the DOM, then delegates pricing math to
|
|
// calculateQuote(state, pricing) without changing the rest of the app.
|
|
function calcQuote() {
|
|
return calculateQuote(readFormState(), getPricingConfig());
|
|
}
|
|
|
|
// --- MAIN UPDATE ---
|
|
// ── update() ─────────────────────────────────────────────────────
|
|
// Master DOM update function. Called on every input change.
|
|
// Sequence: calcQuote → update all displays → renderNudge →
|
|
// updateSavings → updateVsComparison → updateSectionSummaries
|
|
// Also wrapped by the mobile IIFE below to sync _m elements.
|
|
// Do not call renderNudge() directly from other functions;
|
|
// always go through update() to keep _m panel in sync.
|
|
function update() {
|
|
const q = calcQuote();
|
|
const pricing = getPricingConfig();
|
|
const bestM365Rate = Math.max(pricing.RATE_M365_M2M || 0, pricing.RATE_M365 || 0);
|
|
const m365BundleSavings = Math.max(0, bestM365Rate - pricing.RATE_BYOL);
|
|
const render = window.SVSQuoteRender;
|
|
|
|
// ── Onboarding fee logic ──
|
|
// m2m: auto = 50% MRR, manual override allowed, waive toggle available
|
|
// 12-month: auto = 50% off onboarding (25% of MRR), manual override allowed
|
|
// 24-month: complimentary (fully waived), input disabled
|
|
const waivedEl = document.getElementById('onboardingWaived');
|
|
const feeEl = document.getElementById('oneTimeFee');
|
|
const fullOnboarding = Math.round(q.MRR / 2);
|
|
|
|
if (waivedEl && q.contractTerm === '24mo') {
|
|
// 24-month: fully complimentary
|
|
waivedEl.checked = true;
|
|
waivedEl.disabled = true;
|
|
waivedEl.dataset.autoWaived = '1';
|
|
// Preserve any manual override so it survives the round-trip back to m2m/12mo
|
|
if (feeEl && feeEl.dataset.manual) {
|
|
feeEl.dataset.manualValue = feeEl.value;
|
|
delete feeEl.dataset.manual;
|
|
}
|
|
} else if (waivedEl && q.contractTerm === '12mo') {
|
|
// 12-month: 50% off onboarding, not waived — clear any auto-waive state
|
|
waivedEl.disabled = false;
|
|
if (waivedEl.dataset.autoWaived) {
|
|
waivedEl.checked = false;
|
|
delete waivedEl.dataset.autoWaived;
|
|
}
|
|
// Restore manual override if user had one before switching to 24mo
|
|
if (feeEl && !feeEl.dataset.manual && feeEl.dataset.manualValue) {
|
|
feeEl.dataset.manual = '1';
|
|
feeEl.value = feeEl.dataset.manualValue;
|
|
delete feeEl.dataset.manualValue;
|
|
}
|
|
} else if (waivedEl) {
|
|
// m2m: full manual control
|
|
waivedEl.disabled = false;
|
|
if (waivedEl.dataset.autoWaived) {
|
|
waivedEl.checked = false;
|
|
delete waivedEl.dataset.autoWaived;
|
|
}
|
|
// Restore manual override if user had one before switching to 24mo
|
|
if (feeEl && !feeEl.dataset.manual && feeEl.dataset.manualValue) {
|
|
feeEl.dataset.manual = '1';
|
|
feeEl.value = feeEl.dataset.manualValue;
|
|
delete feeEl.dataset.manualValue;
|
|
}
|
|
}
|
|
|
|
const waived = waivedEl?.checked || false;
|
|
let oneTimeFee;
|
|
if (waived) {
|
|
oneTimeFee = 0;
|
|
if (feeEl) { feeEl.value = ''; feeEl.disabled = true; feeEl.placeholder = 'Complimentary'; }
|
|
} else if (q.contractTerm === '12mo') {
|
|
// 12-month: 50% off the standard onboarding fee
|
|
if (feeEl) { feeEl.disabled = false; feeEl.placeholder = '50% off'; }
|
|
if (feeEl && !feeEl.dataset.manual) {
|
|
oneTimeFee = Math.round(fullOnboarding / 2);
|
|
feeEl.value = oneTimeFee > 0 ? oneTimeFee : '';
|
|
} else {
|
|
oneTimeFee = parseFloat(feeEl?.value) || 0;
|
|
}
|
|
} else {
|
|
// m2m: standard auto-calc
|
|
if (feeEl) { feeEl.disabled = false; feeEl.placeholder = 'auto'; }
|
|
if (feeEl && !feeEl.dataset.manual) {
|
|
oneTimeFee = fullOnboarding;
|
|
feeEl.value = oneTimeFee > 0 ? oneTimeFee : '';
|
|
} else {
|
|
oneTimeFee = parseFloat(feeEl?.value) || 0;
|
|
}
|
|
}
|
|
q.oneTimeFee = oneTimeFee;
|
|
const renderOptions = {
|
|
m365BundleSavings,
|
|
oneTimeFee,
|
|
onboardingWaived: waived,
|
|
onboardingWouldBe: fullOnboarding,
|
|
onboardingHalfOff: q.contractTerm === '12mo' && !waived
|
|
};
|
|
|
|
render.renderQuoteUi(q, renderOptions);
|
|
render.renderSidebar(q, renderOptions);
|
|
render.setNudges(render.buildNudges(q, renderOptions));
|
|
|
|
renderNudge();
|
|
updateSavings(q);
|
|
updateVsComparison(q);
|
|
updateSectionSummaries(q);
|
|
|
|
// Active section accent
|
|
['sec-02', 'sec-03', 'sec-01', 'sec-04', 'sec-05', 'sec-06'].forEach(function(secId) {
|
|
var el = document.getElementById(secId);
|
|
if (!el) return;
|
|
var input = el.querySelector('.num-input');
|
|
var isActive = input ? parseInt(input.value) > 0 : false;
|
|
if (secId === 'sec-01') isActive = q.mrr > 0;
|
|
el.classList.toggle('sec-active', isActive);
|
|
});
|
|
|
|
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 = {};
|
|
|
|
function finishSectionAnimation(body, isOpen) {
|
|
body.style.transition = '';
|
|
body.style.overflow = '';
|
|
body.style.height = '';
|
|
body.style.opacity = '';
|
|
body.style.display = isOpen ? '' : 'none';
|
|
}
|
|
|
|
function animateSectionBody(body, open) {
|
|
if (!body) return;
|
|
|
|
if (body._sectionAnimationCleanup) {
|
|
body._sectionAnimationCleanup();
|
|
body._sectionAnimationCleanup = null;
|
|
}
|
|
|
|
body.style.overflow = 'hidden';
|
|
body.style.transition = 'height 0.34s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.22s ease';
|
|
|
|
if (open) {
|
|
body.style.display = '';
|
|
body.style.height = '0px';
|
|
body.style.opacity = '0';
|
|
body.getBoundingClientRect();
|
|
|
|
const targetHeight = body.scrollHeight;
|
|
requestAnimationFrame(() => {
|
|
body.style.height = targetHeight + 'px';
|
|
body.style.opacity = '1';
|
|
});
|
|
} else {
|
|
const startHeight = body.scrollHeight || body.offsetHeight;
|
|
body.style.display = '';
|
|
body.style.height = startHeight + 'px';
|
|
body.style.opacity = '1';
|
|
body.getBoundingClientRect();
|
|
|
|
requestAnimationFrame(() => {
|
|
body.style.height = '0px';
|
|
body.style.opacity = '0';
|
|
});
|
|
}
|
|
|
|
const onEnd = (event) => {
|
|
if (event.target !== body || event.propertyName !== 'height') return;
|
|
body.removeEventListener('transitionend', onEnd);
|
|
body._sectionAnimationCleanup = null;
|
|
finishSectionAnimation(body, open);
|
|
};
|
|
|
|
body._sectionAnimationCleanup = () => {
|
|
body.removeEventListener('transitionend', onEnd);
|
|
finishSectionAnimation(body, open);
|
|
};
|
|
|
|
body.addEventListener('transitionend', onEnd);
|
|
}
|
|
|
|
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');
|
|
// Sync aria-expanded on the section-toggle header
|
|
const header = section.querySelector('.section-toggle');
|
|
if (header) header.setAttribute('aria-expanded', String(isOpen));
|
|
animateSectionBody(body, isOpen);
|
|
// 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();
|
|
}
|
|
|
|
// ── toggleAllSections() / updateToggleAllBtn() ────────────────────
|
|
// Collapse all if any are open; expand all if all are closed.
|
|
// Button label reflects current state.
|
|
const _allSecIds = ['sec-02','sec-03','sec-01','sec-04','sec-05','sec-06'];
|
|
function toggleAllSections() {
|
|
const anyOpen = _allSecIds.some(id => document.getElementById(id)?.classList.contains('sec-open'));
|
|
_allSecIds.forEach(id => {
|
|
const section = document.getElementById(id);
|
|
const body = document.getElementById(id + '-body');
|
|
if (!section || !body) return;
|
|
if (anyOpen) {
|
|
section.classList.remove('sec-open');
|
|
animateSectionBody(body, false);
|
|
}
|
|
else {
|
|
section.classList.add('sec-open');
|
|
animateSectionBody(body, true);
|
|
// 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(calcQuote());
|
|
updateToggleAllBtn();
|
|
}
|
|
function updateToggleAllBtn() {
|
|
const anyOpen = _allSecIds.some(id => document.getElementById(id)?.classList.contains('sec-open'));
|
|
const btn = document.getElementById('toggleAllBtn');
|
|
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';
|
|
}
|
|
|
|
// ── toggleCollapsible(id) ─────────────────────────────────────────
|
|
// Collapses/expands inner content panels (What's Included, Add-Ons).
|
|
// Separate from section-level toggle. Toggles .open on .collapsible-body.
|
|
// Also toggles preview pills (shown when collapsed, hidden when open).
|
|
function toggleCollapsible(id) {
|
|
const body = document.getElementById(id);
|
|
const icon = document.getElementById(id + '-icon');
|
|
const preview = document.getElementById(id + '-preview');
|
|
if (!body) return;
|
|
const open = body.classList.toggle('open');
|
|
if (icon) icon.classList.toggle('open', open);
|
|
if (preview) preview.style.display = open ? 'none' : 'flex';
|
|
// Sync aria-expanded on the collapsible header that controls this body
|
|
const header = body.previousElementSibling;
|
|
if (header && header.classList.contains('collapsible-header')) {
|
|
header.setAttribute('aria-expanded', String(open));
|
|
}
|
|
}
|
|
|
|
// ── toggleAddon(checkId, rowId) ─────────────────────────────────
|
|
// Flips the hidden checkbox + toggles .selected on the visible row.
|
|
// Called via onclick on the <label class="addon-row"> element.
|
|
// The label's onclick fires BEFORE the native checkbox change,
|
|
// so we manually invert cb.checked here instead of reading it.
|
|
function toggleAddon(checkId, rowId) {
|
|
const cb = document.getElementById(checkId);
|
|
const row = document.getElementById(rowId);
|
|
cb.checked = !cb.checked;
|
|
if (row) row.classList.toggle('selected', cb.checked);
|
|
}
|
|
|
|
// ── activateTier(tier) ───────────────────────────────────────────
|
|
// Sets the active VoIP tier pill (basic/standard/premium).
|
|
// Adds .active class to matching .tier-seg, removes from others.
|
|
// Called from onclick on tier labels (alongside update()).
|
|
function activateTier(tier) {
|
|
const el = document.getElementById('voip' + tier.charAt(0).toUpperCase() + tier.slice(1));
|
|
if (el) { el.checked = true; update(); }
|
|
}
|
|
|
|
// Rendering helpers for summaries, savings, nudges, and the VS comparison
|
|
// now live in quote-render.js. They stay available globally so the existing
|
|
// HTML shell and inline handlers do not change.
|
|
|
|
// ── HELPERS ──────────────────────────────────────────────────────
|
|
// stepInput — increment/decrement a num-input by delta, respects min
|
|
function stepInput(id, delta) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
const min = parseInt(el.min) ?? 0;
|
|
el.value = Math.max(min, (parseInt(el.value) || 0) + delta);
|
|
// Pulse feedback
|
|
el.classList.remove('pulse');
|
|
void el.offsetWidth;
|
|
el.classList.add('pulse');
|
|
update();
|
|
}
|
|
|
|
// Persistence and reset behavior now live in quote-persistence.js and stay
|
|
// global so update(), initQuote(), and inline HTML handlers continue to work.
|
|
|
|
// Export/print and theme management live in their own Phase 4 modules and
|
|
// remain available globally for the existing buttons and init flow.
|
|
|
|
// ── initQuote() ──────────────────────────────────────────────────
|
|
// Entry point. Sets quote ref (SVS-YYYYMMDD-XXXX) and date,
|
|
// restores saved state, then calls update() for initial render.
|
|
// Called once at bottom of script.
|
|
function syncAddonPriceLabels() {
|
|
document.querySelectorAll('.addon-price[data-price-key]').forEach(function(el) {
|
|
var key = el.dataset.priceKey;
|
|
var unit = el.dataset.priceUnit || 'unit';
|
|
var val = window[key];
|
|
if (typeof val === 'number') {
|
|
el.textContent = '+$' + val + '/' + unit + '/mo';
|
|
}
|
|
});
|
|
}
|
|
|
|
async function initQuote() {
|
|
await loadPricing();
|
|
syncAddonPriceLabels();
|
|
const now = new Date();
|
|
const months = ['JANUARY','FEBRUARY','MARCH','APRIL','MAY','JUNE','JULY','AUGUST','SEPTEMBER','OCTOBER','NOVEMBER','DECEMBER'];
|
|
const year = now.getFullYear();
|
|
const month = months[now.getMonth()];
|
|
const quoteRef = getOrCreateQuoteRef(now);
|
|
const quoteRefEl = document.getElementById('quoteRef');
|
|
if (quoteRefEl) quoteRefEl.textContent = quoteRef;
|
|
const headerDateEl = document.getElementById('headerDate');
|
|
if (headerDateEl) headerDateEl.textContent = `${month} ${year}`;
|
|
restoreState();
|
|
update();
|
|
updateToggleAllBtn();
|
|
}
|
|
|
|
// ── Keyboard Shortcuts ───────────────────────────────────────────
|
|
// Ctrl+P → Print invoice (overrides browser print)
|
|
// Ctrl+E → Export JSON
|
|
// Ctrl+R → Reset quote (opens confirm modal)
|
|
// Escape → Close sidebar focus / mobile panel (handled in mobile-sync.js)
|
|
document.addEventListener('keydown', function(e) {
|
|
// Skip when user is typing in an input/textarea
|
|
var tag = (document.activeElement || {}).tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
|
|
|
if (e.ctrlKey && !e.shiftKey && !e.altKey) {
|
|
if (e.key === 'p' || e.key === 'P') {
|
|
e.preventDefault();
|
|
if (typeof printInvoice === 'function') printInvoice();
|
|
} else if (e.key === 'e' || e.key === 'E') {
|
|
e.preventDefault();
|
|
if (typeof exportQuoteJSON === 'function') exportQuoteJSON();
|
|
} else if (e.key === 'r' || e.key === 'R') {
|
|
e.preventDefault();
|
|
if (typeof openResetConfirm === 'function') openResetConfirm();
|
|
}
|
|
}
|
|
});
|
|
|
|
initTheme();
|
|
initQuote();
|
|
|
|
// Mobile sidebar cloning and sync now live in mobile-sync.js, which loads
|
|
// after this file so it can wrap the finalized global update() function.
|