/**
* @file Consent-gated analytics and the Site preferences Analytics switch.
*
* Coastal Healthcare Advocates uses Cloudflare Web Analytics, a cookieless,
* privacy-friendly analytics service. It loads only on the production host
* ({@link PROD_HOSTS}), so local dev, deploy previews and branch deploys never
* record traffic. Analytics are **on by default** and load on first visit;
* a visitor can opt out at any time. A browser that sends the Global Privacy
* Control signal starts with them off instead, as the Privacy Notice promises.
*
* There is no first-visit consent prompt. The practice's visitors are in the
* US, so none is required; the banner was removed on 2026-09-18 and the
* opt-out lives in the Site preferences dialog instead.
*
* How it fits together:
*
* - The Site preferences dialog (`prefs.js`, opened from the header and
* footer gear buttons) carries an Analytics
* switch, defaulted on, so a visitor can change their mind at any time. It
* reads and saves the choice through `window.chaConsent`.
* - The visitor's choice is stored in `localStorage` under {@link STORE_KEY}.
* No cookie is ever set by this script. Until a choice is saved, the
* default applies: on, or off under Global Privacy Control ({@link gpc}).
* - Other scripts may call `window.chaTrack(name, props)`. Cloudflare Web
* Analytics has no custom events, so it is always a harmless no-op, kept so
* callers don't need changing if a tool with events is added later.
*
* Loaded as an ES module (`<script type="module">`), so it runs after the
* document has been parsed.
*
* @module consent
*/
/**
* `localStorage` key that holds the visitor's saved {@link ConsentRecord}.
* @type {string}
*/
const STORE_KEY = 'cha-consent'; // name of the localStorage entry for the choice
/**
* Cloudflare Web Analytics site token (Cloudflare dashboard → Analytics & Logs →
* Web Analytics → the coastalhealthcareadvocates.org site → Manage site → JS
* snippet). It is public: it ships in the page like the snippet itself.
* @type {string}
*/
const CF_BEACON_TOKEN = '688c7801543b4bcca9107ccdf7dfc7ba';
/**
* Hosts that count as production. Analytics load only here, so only real
* traffic is recorded.
* @type {string[]}
*/
const PROD_HOSTS = [
'coastalhealthcareadvocates.org',
'www.coastalhealthcareadvocates.org',
];
/**
* Whether this page is being served from production.
* @type {boolean}
*/
const isProd = PROD_HOSTS.includes(location.hostname); // false on localhost and *.netlify.app
/**
* Whether the browser sends a Global Privacy Control signal. It only changes
* the default: a choice the visitor saves here still wins.
* @type {boolean}
*/
const gpc = navigator.globalPrivacyControl === true; // the visitor's browser-wide opt-out
/**
* The shape of the consent choice saved in `localStorage`.
*
* @typedef {Object} ConsentRecord
* @property {true} necessary Strictly-necessary storage — always allowed.
* @property {boolean} analytics Whether the visitor accepted analytics.
* @property {string} ts ISO-8601 timestamp of when the choice was made.
* @property {number} v Schema version of this record (currently `1`),
* so a future change can detect old records.
*/
/**
* Reads the visitor's saved consent choice.
*
* Any failure — storage blocked (e.g. some private-browsing modes), the key
* missing, or the stored value not being valid JSON — is treated as
* "no choice made yet".
*
* @returns {ConsentRecord|null} The saved record, or `null` if there isn't one.
*/
function readConsent() {
// define readConsent()
try {
// storage access and JSON parsing can both throw
// A missing key returns null from getItem; `|| 'null'` turns that into
// JSON that parses cleanly back to null.
return JSON.parse(localStorage.getItem(STORE_KEY) || 'null'); // read and decode the saved record
} catch (_) {
// storage blocked or value corrupt
return null; // treat as "no choice yet"
}
}
/**
* Saves the visitor's consent choice to `localStorage`.
*
* If storage is unavailable the write is silently skipped; the choice still
* applies for the current page view because the record is returned.
*
* @param {boolean} analytics `true` if the visitor accepted analytics.
* @returns {ConsentRecord} The record that was (or would have been) saved.
*/
function writeConsent(analytics) {
// define writeConsent(analytics)
// Build the record: necessary is always on, analytics coerced to a boolean,
// plus a timestamp and schema version.
const value = {
necessary: true,
analytics: !!analytics,
ts: new Date().toISOString(),
v: 1,
};
try {
// storage may be unavailable
localStorage.setItem(STORE_KEY, JSON.stringify(value)); // save the record as JSON
} catch (_) {} // ignore a failed write
return value; // hand the record back to the caller
}
/**
* Injects the Cloudflare Web Analytics beacon.
*
* Does nothing off the production host ({@link isProd}), without a token, or
* if the script tag (`#cf-beacon`) is already in the page. The beacon comes
* from `static.cloudflareinsights.com` and reports to `cloudflareinsights.com`;
* both are allowed in the Content-Security-Policy.
*
* @returns {void}
*/
function loadAnalytics() {
// define loadAnalytics()
if (!isProd || CF_BEACON_TOKEN.startsWith('REPLACE')) return; // production with a real token only
if (document.getElementById('cf-beacon')) return; // already loaded
// Cloudflare's JS snippet, built as an element.
const s = document.createElement('script'); // create a new <script> element
s.id = 'cf-beacon'; // give it an id so it can be found (and removed) later
s.defer = true; // don't block page rendering while it loads
s.src = 'https://static.cloudflareinsights.com/beacon.min.js'; // Cloudflare's beacon
s.setAttribute(
'data-cf-beacon',
JSON.stringify({ token: CF_BEACON_TOKEN }),
); // which site to report under
document.head.appendChild(s); // add it to <head>, which starts the download
}
/**
* Turns analytics off for the rest of this page view, after the visitor
* withdraws consent.
*
* Removes the beacon's script tag. (A script that has already executed can't
* be fully unloaded, but the next page load won't include the beacon at all.)
*
* @returns {void}
*/
function unloadAnalytics() {
// define unloadAnalytics()
document.getElementById('cf-beacon')?.remove(); // remove the script tag if present
}
// ---------------------------------------------------------------------------
// Start-up
// ---------------------------------------------------------------------------
// Make sure `window.chaTrack` always exists so other scripts can call it
// unconditionally. Cloudflare Web Analytics has no custom events, so it does nothing.
window.chaTrack = window.chaTrack || (() => {});
// Restore a choice made on an earlier visit, or fall back to the default
// (analytics on, unless the browser sends Global Privacy Control) when no
// choice has been made yet.
const existing = readConsent(); // look up any saved choice
if (existing ? existing.analytics : !gpc) loadAnalytics(); // analytics accepted, or no choice yet and no GPC: load them
// ---------------------------------------------------------------------------
// Consent API
// ---------------------------------------------------------------------------
/**
* Saves a consent choice and starts or stops analytics to match.
*
* @param {boolean} analytics `true` to accept analytics.
* @returns {void}
*/
function choose(analytics) {
// define choose(analytics)
writeConsent(analytics); // save the choice
if (analytics)
loadAnalytics(); // accepted: start analytics
else unloadAnalytics(); // declined: stop analytics
}
/**
* Consent API for the Site preferences dialog (`prefs.js`).
* @namespace chaConsent
*/
window.chaConsent = {
// expose the API globally
/**
* @returns {boolean} Whether the visitor has accepted analytics.
*/
get: () => {
// saved choice, else the default
const saved = readConsent(); // look up any saved choice
return saved ? !!saved.analytics : !gpc; // explicit choice wins; otherwise on, or off under GPC
},
/**
* Saves the choice and starts or stops analytics.
* @param {boolean} analytics `true` to accept analytics.
* @returns {void}
*/
set: (analytics) => choose(!!analytics), // save and apply
/**
* Forgets the saved choice and applies the default again (on, or off under
* Global Privacy Control). Used by Site preferences' Reset to Defaults.
* @returns {void}
*/
reset: () => {
// back to the default
try {
localStorage.removeItem(STORE_KEY);
} catch (_) {} // forget the saved choice
if (gpc)
unloadAnalytics(); // GPC: default off
else loadAnalytics(); // otherwise default on
},
};