Files
claudetools/projects/acg-website-showcase/js/app.js
Mike Swanson c5d4d3527c 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
2026-06-14 20:05:02 -07:00

262 lines
10 KiB
JavaScript

/* ===========================================================================
Arizona Computer Guru — Sonoran Ledger
Vanilla JS. Theme, calculator, FAQ, steppers, reveal. No dependencies.
=========================================================================== */
(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 ? "☾" : "☀"; // moon / sun
}
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) {}
});
}
// Follow the OS only while the user hasn't explicitly chosen.
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);
}
/* ---- 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 ------------------------------------------------------- */
var form = $("#calcForm");
if (form) {
var EQUIP = 25;
var M365_RATE = 14; // Business Standard
var VOIP_RATE = 28; // GPS-Voice Standard
function intVal(id) {
var el = $("#" + id);
var v = parseInt(el && el.value, 10);
if (isNaN(v) || v < 0) v = 0;
if (v > 500) v = 500;
return v;
}
function numSelect(id) {
var el = $("#" + id);
var v = parseFloat(el && el.value);
return isNaN(v) ? 0 : v;
}
function lineRow(name, detail, cost) {
var zero = cost <= 0 ? " is-zero" : "";
return (
'<div class="lline' + 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 + " &times; " + 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 + " &times; " + money(M365_RATE), m365Cost);
lines += lineRow("Business phones", voip + " &times; " + 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";
var per = endpoints > 0 ? total / endpoints : 0;
$("#perEndpoint").innerHTML = endpoints > 0
? money(per) + " all-in, per endpoint / mo"
: "&mdash; add endpoints to see per-seat cost";
}
function clampInt(v) {
if (isNaN(v) || v < 0) return 0;
return v > 500 ? 500 : v;
}
// Steppers
$$(".stepper").forEach(function (st) {
var input = $("input", st);
$$("button", st).forEach(function (b) {
b.addEventListener("click", function () {
var dir = parseInt(b.getAttribute("data-dir"), 10);
input.value = clampInt(parseInt(input.value, 10) + dir);
recalc();
});
});
// On commit (blur / Enter), snap a typed value back into range so the
// field never shows a number the math has silently clamped.
input.addEventListener("change", function () {
input.value = clampInt(parseInt(input.value, 10));
});
});
// Hand the built estimate to the contact form when "Send me this estimate"
// is clicked (the anchor still scrolls to #contact).
var sendBtn = $("#sendEstimate");
if (sendBtn) {
sendBtn.addEventListener("click", function () {
var msg = $("#cf-msg");
if (!msg) return;
var lines = $$("#ledgerLines .lline")
.filter(function (l) { return !l.classList.contains("is-zero"); })
.map(function (l) {
var name = $(".lname", l).textContent.replace(/\s+/g, " ").trim();
return "- " + name + ": " + $(".lcost", l).textContent.trim();
});
msg.value = "Here is the estimate I built:\n" + lines.join("\n") +
"\n\nMonthly: " + $("#totalMonthly").textContent +
" (" + $("#totalAnnual").textContent + ")\n\nI'd like to talk it through.";
});
}
form.addEventListener("input", recalc);
form.addEventListener("change", recalc);
form.addEventListener("submit", function (e) { e.preventDefault(); });
recalc();
}
/* ---- FAQ accordion ---------------------------------------------------- */
$$(".faq__q").forEach(function (q, i) {
var panel = q.nextElementSibling;
var inner = panel ? panel.firstElementChild : null;
// Wire ARIA relationships (button <-> answer region).
if (panel) {
var pid = "faq-a-" + (i + 1);
var 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";
});
});
// Recompute open panel heights on resize (text reflow)
window.addEventListener("resize", function () {
$$(".faq__q").forEach(function (q) {
if (q.getAttribute("aria-expanded") === "true") {
var panel = q.nextElementSibling;
var inner = panel ? panel.firstElementChild : null;
if (panel && inner) panel.style.maxHeight = (inner.scrollHeight + 8) + "px";
}
});
});
/* ---- Contact form (demo) --------------------------------------------- */
var contact = $("#contactForm");
if (contact) {
contact.addEventListener("submit", function (e) {
e.preventDefault();
var name = $("#cf-name");
var contactField = $("#cf-contact");
var 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"); });
}
})();