sync: auto-sync from GURU-5070 at 2026-06-14 20:04:14
Author: Mike Swanson Machine: GURU-5070 Timestamp: 2026-06-14 20:04:14
This commit is contained in:
283
projects/acg-website-showcase/multipage/js/app.js
Normal file
283
projects/acg-website-showcase/multipage/js/app.js
Normal file
@@ -0,0 +1,283 @@
|
||||
/* ===========================================================================
|
||||
Arizona Computer Guru, Sonoran Ledger (multipage)
|
||||
Shared vanilla JS across all pages. Each block guards on its own DOM.
|
||||
=========================================================================== */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var $ = function (s, c) { return (c || document).querySelector(s); };
|
||||
var $$ = function (s, c) { return Array.prototype.slice.call((c || document).querySelectorAll(s)); };
|
||||
|
||||
/* ---- Theme ------------------------------------------------------------ */
|
||||
var root = document.documentElement;
|
||||
var toggle = $("#themeToggle");
|
||||
var icon = $("[data-theme-icon]");
|
||||
var STORE = "acg-theme";
|
||||
|
||||
function applyTheme(mode) {
|
||||
root.setAttribute("data-theme", mode);
|
||||
var dark = mode === "dark";
|
||||
if (toggle) {
|
||||
toggle.setAttribute("aria-pressed", String(dark));
|
||||
toggle.setAttribute("aria-label", dark ? "Switch to light theme" : "Switch to dark theme");
|
||||
}
|
||||
if (icon) icon.innerHTML = dark ? "☾" : "☀";
|
||||
}
|
||||
|
||||
var saved = null;
|
||||
try { saved = localStorage.getItem(STORE); } catch (e) {}
|
||||
var prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
applyTheme(saved || (prefersDark ? "dark" : "light"));
|
||||
|
||||
if (toggle) {
|
||||
toggle.addEventListener("click", function () {
|
||||
var next = root.getAttribute("data-theme") === "dark" ? "light" : "dark";
|
||||
applyTheme(next);
|
||||
try { localStorage.setItem(STORE, next); } catch (e) {}
|
||||
});
|
||||
}
|
||||
if (window.matchMedia) {
|
||||
var mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
var onChange = function (e) {
|
||||
var explicit = null;
|
||||
try { explicit = localStorage.getItem(STORE); } catch (err) {}
|
||||
if (!explicit) applyTheme(e.matches ? "dark" : "light");
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener("change", onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
}
|
||||
|
||||
/* ---- Skin (Paper / Midnight / Verdigris) ----------------------------- */
|
||||
var skinToggle = $("#skinToggle");
|
||||
var SKIN = "acg-skin";
|
||||
var SKINS = ["ledger", "midnight", "verdigris", "bold"];
|
||||
var SKIN_NAME = { ledger: "Paper", midnight: "Midnight", verdigris: "Verdigris", bold: "Bold" };
|
||||
// Verdigris uses its own cooler documentary photography.
|
||||
var VERDIGRIS_IMG = {
|
||||
"assets/images/hero.png": "assets/images/verdigris/hero.png",
|
||||
"assets/images/about.png": "assets/images/verdigris/about.png",
|
||||
"assets/images/services.png": "assets/images/verdigris/services.png",
|
||||
"assets/images/contact.png": "assets/images/verdigris/contact.png",
|
||||
"assets/images/story.png": "assets/images/verdigris/contact.png"
|
||||
};
|
||||
function swapSkinImages(skin) {
|
||||
$$("img").forEach(function (img) {
|
||||
var orig = img.getAttribute("data-orig-src");
|
||||
if (orig === null) { orig = img.getAttribute("src"); img.setAttribute("data-orig-src", orig); }
|
||||
if (skin === "verdigris" && VERDIGRIS_IMG[orig]) img.setAttribute("src", VERDIGRIS_IMG[orig]);
|
||||
else img.setAttribute("src", orig);
|
||||
});
|
||||
}
|
||||
function applySkin(skin) {
|
||||
if (SKINS.indexOf(skin) < 0) skin = "ledger";
|
||||
root.setAttribute("data-skin", skin);
|
||||
swapSkinImages(skin);
|
||||
if (skinToggle) {
|
||||
var name = SKIN_NAME[skin];
|
||||
skinToggle.setAttribute("aria-label", "Current skin: " + name + ". Switch.");
|
||||
skinToggle.setAttribute("title", "Skin: " + name + " (click to switch)");
|
||||
}
|
||||
}
|
||||
var savedSkin = "ledger";
|
||||
try { savedSkin = localStorage.getItem(SKIN) || "ledger"; } catch (e) {}
|
||||
applySkin(savedSkin);
|
||||
if (skinToggle) {
|
||||
skinToggle.addEventListener("click", function () {
|
||||
var cur = SKINS.indexOf(root.getAttribute("data-skin"));
|
||||
var next = SKINS[(cur + 1) % SKINS.length];
|
||||
applySkin(next);
|
||||
try { localStorage.setItem(SKIN, next); } catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- Dynamic year ----------------------------------------------------- */
|
||||
var yr = $("#year");
|
||||
if (yr) yr.textContent = String(new Date().getFullYear());
|
||||
|
||||
/* ---- Mobile nav ------------------------------------------------------- */
|
||||
var header = $(".site-header");
|
||||
var navToggle = $("#navToggle");
|
||||
if (navToggle && header) {
|
||||
var closeNav = function () {
|
||||
header.classList.remove("nav-open");
|
||||
navToggle.setAttribute("aria-expanded", "false");
|
||||
navToggle.setAttribute("aria-label", "Open menu");
|
||||
};
|
||||
navToggle.addEventListener("click", function () {
|
||||
var open = header.classList.toggle("nav-open");
|
||||
navToggle.setAttribute("aria-expanded", String(open));
|
||||
navToggle.setAttribute("aria-label", open ? "Close menu" : "Open menu");
|
||||
});
|
||||
$$("#navLinks a").forEach(function (a) { a.addEventListener("click", closeNav); });
|
||||
document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeNav(); });
|
||||
}
|
||||
|
||||
/* ---- Money helper ----------------------------------------------------- */
|
||||
function money(n) { return "$" + Math.round(n).toLocaleString("en-US"); }
|
||||
|
||||
/* ---- Calculator (calculator.html) ------------------------------------ */
|
||||
var form = $("#calcForm");
|
||||
if (form) {
|
||||
var EQUIP = 25, M365_RATE = 14, VOIP_RATE = 28;
|
||||
|
||||
function clampInt(v) { if (isNaN(v) || v < 0) return 0; return v > 500 ? 500 : v; }
|
||||
function intVal(id) { return clampInt(parseInt(($("#" + id) || {}).value, 10)); }
|
||||
function numSelect(id) { var v = parseFloat(($("#" + id) || {}).value); return isNaN(v) ? 0 : v; }
|
||||
|
||||
function lineRow(name, detail, cost) {
|
||||
return '<div class="lline' + (cost <= 0 ? " is-zero" : "") + '">' +
|
||||
'<span class="lname">' + name + (detail ? ' <span class="muted">' + detail + "</span>" : "") + "</span>" +
|
||||
'<span class="lcost">' + money(cost) + "</span></div>";
|
||||
}
|
||||
|
||||
function recalc() {
|
||||
var endpoints = intVal("endpoints");
|
||||
var tier = numSelect("gpsTier");
|
||||
var equip = $("#equip").checked;
|
||||
var support = numSelect("support");
|
||||
var m365 = intVal("m365");
|
||||
var voip = intVal("voip");
|
||||
var hosting = numSelect("hosting");
|
||||
|
||||
var gpsCost = endpoints * tier;
|
||||
var equipCost = equip ? EQUIP : 0;
|
||||
var m365Cost = m365 * M365_RATE;
|
||||
var voipCost = voip * VOIP_RATE;
|
||||
|
||||
var tierName = tier === 19 ? "GPS-Basic" : tier === 39 ? "GPS-Advanced" : "GPS-Pro";
|
||||
var supportName = support === 0 ? "" : support === 200 ? "Essential" :
|
||||
support === 380 ? "Standard" : support === 540 ? "Premium" : "Priority";
|
||||
var hostingName = hosting === 0 ? "" : hosting === 15 ? "Starter" :
|
||||
hosting === 35 ? "Business" : "Commerce";
|
||||
|
||||
var lines = "";
|
||||
lines += lineRow(tierName + " monitoring", endpoints + " × " + money(tier), gpsCost);
|
||||
lines += lineRow("Equipment monitoring", "up to 10 devices", equipCost);
|
||||
lines += lineRow((supportName || "Support plan") + " support", supportName ? "bundled labor" : "none", support);
|
||||
lines += lineRow("Microsoft 365", m365 + " × " + money(M365_RATE), m365Cost);
|
||||
lines += lineRow("Business phones", voip + " × " + money(VOIP_RATE), voipCost);
|
||||
lines += lineRow((hostingName || "Web hosting") + " hosting", hostingName ? "managed" : "none", hosting);
|
||||
|
||||
var total = gpsCost + equipCost + support + m365Cost + voipCost + hosting;
|
||||
|
||||
$("#ledgerLines").innerHTML = lines;
|
||||
$("#totalMonthly").textContent = money(total);
|
||||
$("#totalAnnual").textContent = money(total * 12) + " / year";
|
||||
$("#perEndpoint").innerHTML = endpoints > 0
|
||||
? money(total / endpoints) + " all-in, per endpoint / mo"
|
||||
: "add endpoints to see per-seat cost";
|
||||
}
|
||||
|
||||
$$(".stepper").forEach(function (st) {
|
||||
var input = $("input", st);
|
||||
$$("button", st).forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
input.value = clampInt(parseInt(input.value, 10) + parseInt(b.getAttribute("data-dir"), 10));
|
||||
recalc();
|
||||
});
|
||||
});
|
||||
input.addEventListener("change", function () { input.value = clampInt(parseInt(input.value, 10)); });
|
||||
});
|
||||
|
||||
// Carry the built estimate across to the contact page.
|
||||
var sendBtn = $("#sendEstimate");
|
||||
if (sendBtn) {
|
||||
var storeEstimate = function () {
|
||||
var lines = $$("#ledgerLines .lline")
|
||||
.filter(function (l) { return !l.classList.contains("is-zero"); })
|
||||
.map(function (l) {
|
||||
return "- " + $(".lname", l).textContent.replace(/\s+/g, " ").trim() +
|
||||
": " + $(".lcost", l).textContent.trim();
|
||||
});
|
||||
var summary = "Here is the estimate I built:\n" + lines.join("\n") +
|
||||
"\n\nMonthly: " + $("#totalMonthly").textContent +
|
||||
" (" + $("#totalAnnual").textContent + ")\n\nI'd like to talk it through.";
|
||||
try { sessionStorage.setItem("acg-estimate", summary); } catch (e) {}
|
||||
};
|
||||
// 'click' covers keyboard + left-click; 'pointerdown' also catches
|
||||
// middle-click / cmd-click / open-in-new-tab, which never fire 'click'.
|
||||
sendBtn.addEventListener("click", storeEstimate);
|
||||
sendBtn.addEventListener("pointerdown", storeEstimate);
|
||||
}
|
||||
|
||||
form.addEventListener("input", recalc);
|
||||
form.addEventListener("change", recalc);
|
||||
form.addEventListener("submit", function (e) { e.preventDefault(); });
|
||||
recalc();
|
||||
}
|
||||
|
||||
/* ---- FAQ accordion (contact.html) ------------------------------------ */
|
||||
$$(".faq__q").forEach(function (q, i) {
|
||||
var panel = q.nextElementSibling;
|
||||
var inner = panel ? panel.firstElementChild : null;
|
||||
if (panel) {
|
||||
var pid = "faq-a-" + (i + 1), qid = "faq-q-" + (i + 1);
|
||||
panel.id = pid; q.id = qid;
|
||||
panel.setAttribute("role", "region");
|
||||
panel.setAttribute("aria-labelledby", qid);
|
||||
q.setAttribute("aria-controls", pid);
|
||||
}
|
||||
q.addEventListener("click", function () {
|
||||
var open = q.getAttribute("aria-expanded") === "true";
|
||||
q.setAttribute("aria-expanded", String(!open));
|
||||
if (panel) panel.style.maxHeight = open ? "0px" : (inner.scrollHeight + 8) + "px";
|
||||
});
|
||||
});
|
||||
window.addEventListener("resize", function () {
|
||||
$$(".faq__q").forEach(function (q) {
|
||||
if (q.getAttribute("aria-expanded") === "true") {
|
||||
var panel = q.nextElementSibling, inner = panel ? panel.firstElementChild : null;
|
||||
if (panel && inner) panel.style.maxHeight = (inner.scrollHeight + 8) + "px";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ---- Contact form (contact.html) ------------------------------------- */
|
||||
var contact = $("#contactForm");
|
||||
if (contact) {
|
||||
// Prefill from a calculator estimate, if one was just built.
|
||||
var msg = $("#cf-msg");
|
||||
if (msg) {
|
||||
try {
|
||||
var est = sessionStorage.getItem("acg-estimate");
|
||||
if (est) {
|
||||
msg.value = est;
|
||||
sessionStorage.removeItem("acg-estimate");
|
||||
var fn = $("#formNote");
|
||||
if (fn) fn.textContent = "Your estimate is attached below. Add your name and we'll take it from there.";
|
||||
// Bring the form into view and focus it so the handoff is visible.
|
||||
if (contact.scrollIntoView) contact.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
var nameField = $("#cf-name");
|
||||
if (nameField) nameField.focus({ preventScroll: true });
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
contact.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
var name = $("#cf-name"), contactField = $("#cf-contact"), note = $("#formNote");
|
||||
if (!name.value.trim() || !contactField.value.trim()) {
|
||||
note.textContent = "Please add your name and a phone or email so we can reach you.";
|
||||
note.style.color = "var(--accent-ink)";
|
||||
(name.value.trim() ? contactField : name).focus();
|
||||
return;
|
||||
}
|
||||
note.textContent = "Thanks, " + name.value.trim().split(" ")[0] +
|
||||
". In a live build this reaches our Tucson team. (Demo: nothing was sent.)";
|
||||
note.style.color = "var(--good)";
|
||||
contact.reset();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- Reveal on scroll ------------------------------------------------- */
|
||||
var reveals = $$(".reveal");
|
||||
if ("IntersectionObserver" in window && reveals.length) {
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (en) {
|
||||
if (en.isIntersecting) { en.target.classList.add("in"); io.unobserve(en.target); }
|
||||
});
|
||||
}, { rootMargin: "0px 0px -8% 0px", threshold: 0.08 });
|
||||
reveals.forEach(function (el) { io.observe(el); });
|
||||
} else {
|
||||
reveals.forEach(function (el) { el.classList.add("in"); });
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user