GPT is about to go nuts with my project.
This commit is contained in:
@@ -1275,50 +1275,94 @@ function exportQuoteJSON() {
|
||||
}
|
||||
|
||||
// ── THEME TOGGLE ─────────────────────────────────────────────────
|
||||
// Light theme is a separate CSS file imported dynamically on demand.
|
||||
// Dark mode = base stylesheet only (no extra link element).
|
||||
// Light mode = base + SVS-MSP-Calculator-light.css.
|
||||
// Base CSS is the default dark theme.
|
||||
// Variant themes are imported dynamically on demand:
|
||||
// light = base + SVS-MSP-Calculator-light.css
|
||||
// glass = base + SVS-MSP-Calculator-glass.css
|
||||
// Preference persisted to localStorage under 'svs-theme'.
|
||||
// initTheme() called first so the page never flashes the wrong theme.
|
||||
// initTheme() runs before initQuote() so the UI boots in the saved theme.
|
||||
|
||||
const THEME_STORAGE_KEY = 'svs-theme';
|
||||
const THEME_STYLESHEET_ID = 'themeStylesheetLink';
|
||||
const THEME_ASSET_VERSION = '20260313-02';
|
||||
const SVG_SUN = '<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
|
||||
const SVG_MOON = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
|
||||
const SVG_GLASS = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l2.7 5.3L20 11l-5.3 2.7L12 19l-2.7-5.3L4 11l5.3-2.7L12 3z"/><path d="M18.5 3.5l.8 1.7L21 6l-1.7.8-.8 1.7-.8-1.7L16 6l1.7-.8.8-1.7z"/></svg>';
|
||||
const THEME_ORDER = ['dark', 'light', 'glass'];
|
||||
const THEME_CONFIG = {
|
||||
dark: {
|
||||
icon: SVG_MOON,
|
||||
href: null,
|
||||
label: 'Dark'
|
||||
},
|
||||
light: {
|
||||
icon: SVG_SUN,
|
||||
href: 'SVS-MSP-Calculator-light.css',
|
||||
label: 'Light'
|
||||
},
|
||||
glass: {
|
||||
icon: SVG_GLASS,
|
||||
href: 'SVS-MSP-Calculator-glass.css',
|
||||
label: 'Glass'
|
||||
}
|
||||
};
|
||||
|
||||
function toggleTheme() {
|
||||
const link = document.getElementById('lightThemeLink');
|
||||
function getSavedTheme() {
|
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
return THEME_ORDER.includes(saved) ? saved : 'dark';
|
||||
}
|
||||
|
||||
function getCurrentTheme() {
|
||||
const applied = document.documentElement.dataset.theme;
|
||||
return THEME_ORDER.includes(applied) ? applied : getSavedTheme();
|
||||
}
|
||||
|
||||
function updateThemeToggleUi(theme) {
|
||||
const icon = document.getElementById('themeToggleIcon');
|
||||
const btn = document.getElementById('themeToggle');
|
||||
if (link) {
|
||||
// Currently light → switch to dark
|
||||
link.remove();
|
||||
localStorage.setItem('svs-theme', 'dark');
|
||||
if (icon) icon.innerHTML = SVG_SUN;
|
||||
if (btn) btn.setAttribute('title', 'Switch to light theme');
|
||||
} else {
|
||||
// Currently dark → switch to light
|
||||
const el = document.createElement('link');
|
||||
el.id = 'lightThemeLink';
|
||||
el.rel = 'stylesheet';
|
||||
el.href = 'SVS-MSP-Calculator-light.css';
|
||||
document.head.appendChild(el);
|
||||
localStorage.setItem('svs-theme', 'light');
|
||||
if (icon) icon.innerHTML = SVG_MOON;
|
||||
if (btn) btn.setAttribute('title', 'Switch to dark theme');
|
||||
const btn = document.getElementById('themeToggle');
|
||||
const currentIndex = THEME_ORDER.indexOf(theme);
|
||||
const nextTheme = THEME_ORDER[(currentIndex + 1) % THEME_ORDER.length];
|
||||
const currentLabel = THEME_CONFIG[theme].label;
|
||||
const nextLabel = THEME_CONFIG[nextTheme].label;
|
||||
if (icon) icon.innerHTML = THEME_CONFIG[theme].icon;
|
||||
if (btn) {
|
||||
const uiLabel = `Theme: ${currentLabel}. Click to switch to ${nextLabel}.`;
|
||||
btn.setAttribute('title', uiLabel);
|
||||
btn.setAttribute('aria-label', uiLabel);
|
||||
}
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
if (localStorage.getItem('svs-theme') === 'light') {
|
||||
function applyTheme(theme) {
|
||||
const nextTheme = THEME_ORDER.includes(theme) ? theme : 'dark';
|
||||
const existing = document.getElementById(THEME_STYLESHEET_ID);
|
||||
const legacyLight = document.getElementById('lightThemeLink');
|
||||
if (existing) existing.remove();
|
||||
if (legacyLight) legacyLight.remove();
|
||||
|
||||
const themeHref = THEME_CONFIG[nextTheme].href;
|
||||
if (themeHref) {
|
||||
const el = document.createElement('link');
|
||||
el.id = 'lightThemeLink';
|
||||
el.rel = 'stylesheet';
|
||||
el.href = 'SVS-MSP-Calculator-light.css';
|
||||
el.id = THEME_STYLESHEET_ID;
|
||||
el.rel = 'stylesheet';
|
||||
el.href = `${themeHref}?v=${THEME_ASSET_VERSION}`;
|
||||
el.dataset.theme = nextTheme;
|
||||
document.head.appendChild(el);
|
||||
const icon = document.getElementById('themeToggleIcon');
|
||||
const btn = document.getElementById('themeToggle');
|
||||
if (icon) icon.innerHTML = SVG_MOON;
|
||||
if (btn) btn.setAttribute('title', 'Switch to dark theme');
|
||||
}
|
||||
|
||||
document.documentElement.dataset.theme = nextTheme;
|
||||
localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
|
||||
updateThemeToggleUi(nextTheme);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const currentTheme = getCurrentTheme();
|
||||
const currentIndex = THEME_ORDER.indexOf(currentTheme);
|
||||
const nextTheme = THEME_ORDER[(currentIndex + 1) % THEME_ORDER.length];
|
||||
applyTheme(nextTheme);
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
applyTheme(getSavedTheme());
|
||||
}
|
||||
|
||||
// ── initQuote() ──────────────────────────────────────────────────
|
||||
@@ -1361,33 +1405,54 @@ 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().
|
||||
// The desktop sidebar is the single markup source of truth.
|
||||
// On boot, we clone it into #mobilePanelContent and suffix all IDs with _m.
|
||||
// The update() wrapper below then syncs dynamic values/classes into the clone.
|
||||
//
|
||||
// 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.
|
||||
// 2. Ensure the mobile sync map below includes it if it changes at runtime
|
||||
// 3. Avoid separate handwritten mobile markup for sidebar content
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── MOBILE QUOTE PANEL IIFE ──────────────────────────────────────
|
||||
// Encapsulates all mobile panel logic to avoid polluting global scope.
|
||||
// ARCHITECTURE:
|
||||
// The real sidebar lives in .side-col (desktop).
|
||||
// The panel contains a STATIC DUPLICATE with _m suffixed IDs.
|
||||
// The mobile panel gets a JS-generated clone with _m suffixed IDs.
|
||||
// update() is wrapped here to sync _m elements after every update.
|
||||
// openMobilePanel / closeMobilePanel are exposed on window.
|
||||
// Do NOT DOM-move the real sidebar into the panel —
|
||||
// Do NOT DOM-move the real desktop sidebar into the panel —
|
||||
// it permanently breaks desktop layout on resize.
|
||||
(function() {
|
||||
// Panel uses a static duplicate sidebar (_m IDs) — no DOM moving needed.
|
||||
function buildMobileSidebar() {
|
||||
var container = document.getElementById('mobilePanelContent');
|
||||
var desktopSidebar = document.querySelector('.side-col .sidebar');
|
||||
if (!container || !desktopSidebar || container.children.length) return;
|
||||
|
||||
var mobileSidebar = desktopSidebar.cloneNode(true);
|
||||
mobileSidebar.removeAttribute('id');
|
||||
|
||||
mobileSidebar.querySelectorAll('[id]').forEach(function(el) {
|
||||
el.id = el.id + '_m';
|
||||
});
|
||||
|
||||
var mobileHstToggle = mobileSidebar.querySelector('#hstToggle_m');
|
||||
if (mobileHstToggle) {
|
||||
mobileHstToggle.onchange = function() {
|
||||
var desktopHstToggle = document.getElementById('hstToggle');
|
||||
if (desktopHstToggle) desktopHstToggle.checked = this.checked;
|
||||
update();
|
||||
};
|
||||
}
|
||||
|
||||
var mobileExportJson = mobileSidebar.querySelector('#btnExportJSON_m');
|
||||
if (mobileExportJson) mobileExportJson.removeAttribute('id');
|
||||
|
||||
container.appendChild(mobileSidebar);
|
||||
}
|
||||
|
||||
buildMobileSidebar();
|
||||
|
||||
window.openMobilePanel = function() {
|
||||
var panel = document.getElementById('mobileQuotePanel');
|
||||
@@ -1437,6 +1502,84 @@ initQuote();
|
||||
if (src && dst) dst.checked = src.checked;
|
||||
}
|
||||
|
||||
var sidebarSyncMap = {
|
||||
html: [
|
||||
'clientNameDisplay',
|
||||
'sl-users-val',
|
||||
'sl-endpoints-val',
|
||||
'sl-servers-val',
|
||||
'sl-zt-val',
|
||||
'sl-voip-val',
|
||||
'sl-admin-val',
|
||||
'mrrDisplay',
|
||||
'annualDisplay',
|
||||
'perUserDisplay',
|
||||
'perUserBreakdown',
|
||||
'm365SaveAmt',
|
||||
'sl-discount-val',
|
||||
'sl-base-mrr-val',
|
||||
'sl-hst-val',
|
||||
'sl-hst-total-val',
|
||||
'sl-otf-val',
|
||||
'vs-svs-annual',
|
||||
'vs-1man-cost',
|
||||
'vs-1man-save',
|
||||
'vs-1man-save-lbl',
|
||||
'vs-5man-cost',
|
||||
'vs-5man-save',
|
||||
'vs-5man-save-lbl',
|
||||
'vs-footnote',
|
||||
'nudgeText',
|
||||
'nudgeCounter',
|
||||
'sl-users-sub',
|
||||
'sl-endpoints-sub',
|
||||
'sl-admin-sub',
|
||||
'adminWaivedAmt'
|
||||
],
|
||||
class: [
|
||||
'sl-users',
|
||||
'sl-users-sub',
|
||||
'sl-endpoints',
|
||||
'sl-endpoints-sub',
|
||||
'sl-admin-sub',
|
||||
'sl-servers',
|
||||
'sl-zt',
|
||||
'sl-voip',
|
||||
'sl-admin',
|
||||
'sideNote-m365',
|
||||
'sideNote-byol',
|
||||
'vsComparison',
|
||||
'perUserRow',
|
||||
'perUserBreakdown',
|
||||
'sl-discount-row',
|
||||
'sl-base-mrr-row',
|
||||
'sl-hst-row',
|
||||
'sl-hst-total-row',
|
||||
'sl-otf-row',
|
||||
'vs-1man-save-row',
|
||||
'vs-1man-save',
|
||||
'vs-1man-save-lbl',
|
||||
'vs-5man-save-row',
|
||||
'vs-5man-save',
|
||||
'vs-5man-save-lbl',
|
||||
'nudgeBanner',
|
||||
'adminWaivedSavings'
|
||||
],
|
||||
style: [
|
||||
'sl-users-sub',
|
||||
'sl-endpoints-sub',
|
||||
'sl-admin-sub',
|
||||
'perUserRow'
|
||||
],
|
||||
checked: [
|
||||
'hstToggle'
|
||||
]
|
||||
};
|
||||
|
||||
function runSidebarSync(ids, syncFn) {
|
||||
ids.forEach(syncFn);
|
||||
}
|
||||
|
||||
// ── UPDATE WRAPPER ─────────────────────────────────────────────
|
||||
// Wraps the global update() to also sync the mobile panel.
|
||||
// _origUpdate = the real update() defined above.
|
||||
@@ -1445,70 +1588,10 @@ initQuote();
|
||||
var _origUpdate = window.update;
|
||||
window.update = function() {
|
||||
_origUpdate();
|
||||
// Sync all mirrored sidebar elements
|
||||
syncEl('clientNameDisplay');
|
||||
syncEl('sl-users-val');
|
||||
syncEl('sl-endpoints-val');
|
||||
syncEl('sl-servers-val');
|
||||
syncEl('sl-zt-val');
|
||||
syncEl('sl-voip-val');
|
||||
syncEl('sl-admin-val');
|
||||
syncEl('mrrDisplay');
|
||||
syncEl('annualDisplay');
|
||||
syncEl('perUserDisplay');
|
||||
syncEl('perUserBreakdown');
|
||||
syncEl('m365SaveAmt');
|
||||
syncEl('sl-discount-val');
|
||||
syncEl('sl-base-mrr-val');
|
||||
syncEl('sl-hst-val');
|
||||
syncEl('sl-hst-total-val');
|
||||
syncEl('sl-otf-val');
|
||||
syncEl('vs-svs-annual');
|
||||
syncEl('vs-1man-cost');
|
||||
syncEl('vs-1man-save');
|
||||
syncEl('vs-1man-save-lbl');
|
||||
syncEl('vs-5man-cost');
|
||||
syncEl('vs-5man-save');
|
||||
syncEl('vs-5man-save-lbl');
|
||||
syncEl('vs-footnote');
|
||||
syncEl('nudgeText');
|
||||
syncEl('nudgeCounter');
|
||||
syncEl('sl-users-sub');
|
||||
syncEl('sl-endpoints-sub');
|
||||
syncEl('sl-admin-sub');
|
||||
syncClass('sl-users');
|
||||
syncClass('sl-users-sub');
|
||||
syncClass('sl-endpoints');
|
||||
syncClass('sl-endpoints-sub');
|
||||
syncClass('sl-admin-sub');
|
||||
syncClass('sl-servers');
|
||||
syncClass('sl-zt');
|
||||
syncClass('sl-voip');
|
||||
syncClass('sl-admin');
|
||||
syncClass('sideNote-m365');
|
||||
syncClass('sideNote-byol');
|
||||
syncClass('vsComparison');
|
||||
syncClass('perUserRow');
|
||||
syncClass('perUserBreakdown');
|
||||
syncClass('sl-discount-row');
|
||||
syncClass('sl-base-mrr-row');
|
||||
syncClass('sl-hst-row');
|
||||
syncClass('sl-hst-total-row');
|
||||
syncClass('sl-otf-row');
|
||||
syncClass('vs-1man-save-row');
|
||||
syncClass('vs-1man-save');
|
||||
syncClass('vs-1man-save-lbl');
|
||||
syncClass('vs-5man-save-row');
|
||||
syncClass('vs-5man-save');
|
||||
syncClass('vs-5man-save-lbl');
|
||||
syncClass('nudgeBanner');
|
||||
syncClass('adminWaivedSavings');
|
||||
syncEl('adminWaivedAmt');
|
||||
syncStyle('sl-users-sub');
|
||||
syncStyle('sl-endpoints-sub');
|
||||
syncStyle('sl-admin-sub');
|
||||
syncStyle('perUserRow');
|
||||
syncChecked('hstToggle');
|
||||
runSidebarSync(sidebarSyncMap.html, syncEl);
|
||||
runSidebarSync(sidebarSyncMap.class, syncClass);
|
||||
runSidebarSync(sidebarSyncMap.style, syncStyle);
|
||||
runSidebarSync(sidebarSyncMap.checked, syncChecked);
|
||||
// Pill MRR — show effective MRR with label
|
||||
var mrr = document.getElementById('mrrDisplay');
|
||||
var pill = document.getElementById('mobilePillMrr');
|
||||
|
||||
Reference in New Issue
Block a user