Skip to content
Coastal Healthcare Advocates

Source: consent.js

/**
 * @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
	},
};