/**
* @file Calendly's scheduler, embedded inline instead of linked to.
*
* The consultation calendar used to be an outbound link to calendly.com. It is
* now an **inline embed**: the scheduler sits in the page — the closing
* `#schedule` band, and a panel in the contact dialog — so a visitor books
* without leaving the site.
*
* ## Why not Calendly's `widget.js`?
*
* Calendly's documented inline embed is a `<div class="calendly-inline-widget">`
* plus their `widget.js`. For an inline embed that script does three things:
* it builds an `<iframe>` whose URL carries `embed_domain` and
* `embed_type=Inline`, shows a spinner, and (with `data-resize`) sizes the
* frame from the `calendly.page_height` messages the framed page posts. This
* module does the same from first-party code, so the site's CSP only has to
* allow `https://calendly.com` on `frame-src`, and no third-party script runs
* in the page that holds the intake form. Calendly documents the plain iframe
* as a supported alternative.
*
* ## Authored markup
*
* A mount that shows the scheduler as soon as the visitor scrolls near it:
*
* ```html
* <div class="calendly-embed" data-calendly="https://calendly.com/…"
* data-calendly-title="Pick a time (Calendly scheduler)">
* <p class="calendly-fallback"><a class="btn" href="https://calendly.com/…">Schedule…</a></p>
* <p class="calendly-alt">Calendar not loading? <a href="https://calendly.com/…">Book on Calendly</a></p>
* </div>
* ```
*
* - `.calendly-fallback` is the **no-JavaScript fallback** (and what prints):
* the plain link out. It hides once the frame is in place.
* - `.calendly-alt` shows only beside a live frame: the way out for a visitor
* whose network or content blocker refuses the frame, which a page can't
* detect across origins.
*
* A mount that waits to be asked for — the dialog's — is authored `hidden`
* and opened by a disclosure button:
*
* ```html
* <button type="button" data-calendly-toggle aria-expanded="false"
* aria-controls="cm-booking">Book time with us</button>
* <div id="cm-booking" class="calendly-embed calendly-embed--panel"
* data-calendly="…" hidden>…</div>
* ```
*
* The style guide borrows the dialog but its stylesheet is built from
* `style-guide.html` and `src/js/` alone, so the class names in these two
* examples are also what keeps the embed's rules in `style-guide.css`.
*
* Toggle clicks are handled by delegation, so a dialog that `modal.js` imports
* into a secondary page later needs no setup call.
*
* ## When the frame loads
*
* Never on page load. A visible mount loads when it scrolls within 600px of
* the viewport; a hidden one when its toggle opens it. Closing the contact
* dialog (`contact:close`) collapses its panel and removes the frame, so every
* open starts fresh and a closed dialog keeps no third-party frame alive.
*
* ## Warming, so a click doesn't wait
*
* Calendly's scheduler takes a second or two to boot, which the visitor used to
* spend watching the loading note. Two steps move that off the click. Both wait
* for the page to have loaded and the browser to be idle, so neither competes
* with the page itself, and neither reaches Calendly for a visitor who is not
* being offered a calendar (`data-booked`, below):
*
* - a `preconnect` to Calendly, which opens the connection — DNS, TCP, TLS —
* and fetches nothing: no page of Calendly's loads, so none of its cookies
* are set. Skipped once a mount has loaded, which is a real connection
* already.
* - the contact dialog's booking panel is **loaded while still hidden**, on
* `contact:open`. Its toggle lives inside the dialog, so the dialog opening
* is the earliest a visitor can be reaching for it. `contact:close` takes the
* frame out again, so each open warms afresh — and a dialog closed before the
* idle moment arrives is never warmed at all.
*
* What the hidden frame buys is the network, not the drawing. It fetches
* Calendly's page, its stylesheet and script chunks and its `initial_settings`
* availability call, then stops: Chromium throttles rendering in a frame that
* isn't visible, so it reports no height while it waits, and the layout its
* zero width would otherwise imply decides nothing — it lays out at the panel's
* real width once the panel opens. Measured on the homepage 2026-09-18 — two
* sets of three runs, clicking "Book time with us" two seconds after opening the
* dialog — the calendar arrived in a median 1.4s warmed against 4.5s cold. The
* loading note still shows for a moment; what has gone is the wait for the
* fetching. Calendly's own boot time is variable, so treat these as a shape
* rather than a promise.
*
* The `#schedule` band needs none of this — it already loads 600px before it
* reaches the viewport.
*
* ## Messages from the frame
*
* Only messages whose origin is `https://calendly.com` **and** whose source is
* one of our frames are read (so two embeds on a page never resize each
* other, the limit Calendly's own script has). `calendly.page_height` sizes
* the frame. `calendly.date_and_time_selected` marks a change of step, which
* replaces the frame's content from the top: the embed is scrolled back into
* view if its top has left the screen.
*
* ## The site's own thanks
*
* `calendly.event_scheduled` means the booking is made. If the mount authors a
* confirmation —
*
* ```html
* <div class="calendly-booked" data-calendly-booked role="status" hidden>…</div>
* ```
*
* — the frame is removed and that panel shown and focused in its place, so the
* visitor ends on the site's thanks (the beacon's light comes on, as for a
* sent message) and not on Calendly's confirmation and sign-up prompt. A
* `calendly:booked` event then bubbles from the mount; `modal.js` uses it to
* tidy the dialog around the panel. Calendly tells the page *that* a booking
* was made, never which slot (the payload is two API URIs the browser can't
* read), so the panel's copy points at Calendly's confirmation email for the
* date and time. A mount with no such panel keeps Calendly's own confirmation.
*
* ## Once booked, not asked again
*
* A booking is also remembered: `localStorage` gets {@link BOOKED_KEY} with the
* time, and for {@link BOOKED_TTL} the site stops inviting that visitor to
* book. `data-booked` on `<html>` is the switch and `src/input.css` does the
* hiding: every `[data-calendly]` mount (bar the one showing its thanks), every
* `[data-calendly-toggle]`, and anything marked `data-calendly-offer` (the
* success panel's "Book time with us"). Copy marked `data-calendly-after`,
* authored `hidden`, stands in where a sentence needs rewording. It is a
* stylesheet rule so that a dialog `modal.js` imports later needs no setup.
* Only the fact and the time of a booking are stored, never its details.
*
* The footer's Calendly icon stays, and it still has to lead somewhere. A link
* to `#schedule` is an explicit ask for the calendar, so following one brings
* everything back for that page view: a click on this page, or arriving at
* `index.html#schedule` from another page (a fresh navigation only, since a
* reload or the back button lands on that URL without anyone asking). The
* record is left alone, so the next page view is quiet again.
*
* Calendly's cookie notice is deliberately **not** suppressed
* (`hide_gdpr_banner`): hiding it would make this site responsible for
* consent to Calendly's cookies.
*
* Loaded as an ES module on every page that carries the contact dialog.
*
* @module calendly
*/
/** The only origin this module will frame or listen to. @type {string} */
const CALENDLY_ORIGIN = 'https://calendly.com'; // Calendly's scheduling pages
/** Shortest height worth acting on, in px: Calendly reports a few px while it boots, and those are skipped. @type {number} */
const MIN_HEIGHT = 480; // floor for a reported height
/** Tallest the frame may be sized, in px. @type {number} */
const MAX_HEIGHT = 2400; // ceiling for a reported height
/** Campaign parameters passed from the page's URL to the booking, as Calendly's script does. @type {string[]} */
const UTM_KEYS = ['utm_campaign', 'utm_source', 'utm_medium', 'utm_content', 'utm_term']; // the five UTM fields
/** `localStorage` key recording that this visitor booked, and when (`{ at: ms }`). @type {string} */
const BOOKED_KEY = 'cha-booked'; // name of the localStorage entry
/** How long a booking keeps the invitations to book out of the way, in ms (30 days). @type {number} */
const BOOKED_TTL = 30 * 24 * 60 * 60 * 1000; // by then the call has happened, and another may be wanted
/** The fragment naming the booking band: a link to it is an ask for the calendar. @type {string} */
const BAND_HASH = '#schedule'; // the closing band on the homepage
/** How long to wait for an idle moment before warming anyway, in ms. @type {number} */
const IDLE_TIMEOUT = 500; // a page that stays busy shouldn't put the warming off for ever
/** `<html>`, which carries `data-booked` for the stylesheet. @type {HTMLElement} */
const root = document.documentElement; // where the switch lives
/** Every mount with a frame in it, and that frame. @type {Map<HTMLElement, HTMLIFrameElement>} */
const live = new Map(); // nothing loaded yet
/** Whether anything has opened a connection to Calendly yet. @type {boolean} */
let warmed = false; // no hint sent and no frame loaded
/** Whether the contact dialog is open, so warming that arrives after it closes can be dropped. @type {boolean} */
let dialogOpen = false; // it starts closed
/**
* Whether this visitor booked within the last {@link BOOKED_TTL}. An expired,
* future-dated or unreadable record is deleted, as `theme.js` does with its own.
*
* @returns {boolean} `true` while a booking is remembered.
*/
function hasBooked() { // define hasBooked()
try { // localStorage can throw when blocked
const raw = localStorage.getItem(BOOKED_KEY); // read the saved text
if (!raw) return false; // nothing saved
let saved = null; // the parsed record, if any
try { saved = JSON.parse(raw); } catch {} // decode it; bad JSON falls through to removal
const age = saved ? Date.now() - saved.at : NaN; // NaN fails both comparisons below
if (age >= 0 && age < BOOKED_TTL) return true; // fresh, and not from the future
localStorage.removeItem(BOOKED_KEY); // expired or invalid: clean it up
} catch {} // storage unavailable: nothing is remembered
return false; // no booking on record
}
/**
* Records that a booking was just made: the time, and nothing about it.
*
* @returns {void}
*/
function rememberBooked() { // define rememberBooked()
try { localStorage.setItem(BOOKED_KEY, JSON.stringify({ at: Date.now() })); } catch {} // save it; ignore storage errors
}
/**
* Whether the visitor got here by following a link to the booking band (the
* footer's Calendly icon on another page). Only a fresh navigation counts: a
* reload or the back button lands on the same URL without anyone asking.
*
* @returns {boolean} `true` if this page view began as an ask for the calendar.
*/
function arrivedAsking() { // define arrivedAsking()
if (window.location.hash !== BAND_HASH) return false; // some other address: no
const nav = /** @type {PerformanceNavigationTiming|undefined} */ (performance.getEntriesByType?.('navigation')[0]); // how this page view began
return !nav || nav.type === 'navigate'; // a followed link or typed address (or a browser that can't say)
}
/**
* The URL to frame: the mount's Calendly link plus the two parameters that
* tell Calendly it is an inline embed (which is what makes it post its height
* and booking events to this page), and any campaign parameters on the page.
* A query string authored on the mount is kept: the pages pass
* `hide_event_type_details=1`, which drops Calendly's own event header from
* the calendar step (it returns on the final step) and with it ~180px.
*
* @param {string|undefined} base The mount's `data-calendly` value.
* @returns {string|null} The frame URL, or null if `base` isn't a Calendly URL.
*/
function embedUrl(base) { // define embedUrl(base)
let url; // the parsed link
try { // a malformed value throws
url = new URL(base); // parse it
} catch { // not a URL:
return null; // nothing to frame
}
if (url.origin !== CALENDLY_ORIGIN) return null; // only ever frame Calendly
const here = new URLSearchParams(window.location.search); // this page's query string
UTM_KEYS.forEach((key) => { // for each campaign field:
if (here.has(key)) url.searchParams.set(key, here.get(key)); // pass it on to the booking
});
url.searchParams.set('embed_domain', window.location.host); // who is embedding
url.searchParams.set('embed_type', 'Inline'); // and how
return url.href; // the finished URL
}
/**
* Runs `fn` once the page has finished loading and the browser has a spare
* moment — immediately idle if loading is already done. Warming is worth
* nothing if it slows the page down to do it, so nothing here jumps the queue;
* {@link IDLE_TIMEOUT} keeps a page that never goes quiet from putting it off
* for ever. (Safari only got `requestIdleCallback` in 18.2, hence the timer.)
*
* @param {function(): void} fn Callback to run.
* @returns {void}
*/
function whenIdle(fn) { // define whenIdle(fn)
const soon = () => { // what to do once the page has loaded:
if (window.requestIdleCallback) window.requestIdleCallback(fn, { timeout: IDLE_TIMEOUT }); // wait for a spare moment
else setTimeout(fn, 200); // no such thing here: just give the load a moment to settle
};
if (document.readyState === 'complete') soon(); // the load event has been and gone
else window.addEventListener('load', soon, { once: true }); // otherwise wait for it
}
/**
* Opens the connection to Calendly without fetching anything from it, so the
* first frame starts at its HTTP request instead of three round trips before
* it. A `preconnect` is DNS, TCP and TLS only: no page of Calendly's is
* loaded, so none of its cookies are set, and because nothing is fetched there
* is no CSP directive for it to pass (the `frame-src` grant still covers the
* frame itself). No `crossorigin` — the frame's navigation isn't a CORS
* request, and an anonymous connection wouldn't be the one it reuses.
*
* @returns {void}
*/
function warmConnection() { // define warmConnection()
if (warmed) return; // a hint or a frame has already been sent: stop
warmed = true; // once per page view
const link = document.createElement('link'); // the hint
link.rel = 'preconnect'; // open the connection, fetch nothing
link.href = CALENDLY_ORIGIN; // to Calendly
document.head.append(link); // the browser acts on it as it lands
}
/**
* Puts the scheduler into a mount: a loading note, then the frame. The
* authored fallback stays in the DOM (hidden by `.is-live`) for print.
* Does nothing if the mount is already live or names no Calendly URL.
*
* @param {HTMLElement} mount A `[data-calendly]` element.
* @returns {void}
*/
function load(mount) { // define load(mount)
if (live.has(mount)) return; // already showing: stop
const src = embedUrl(mount.dataset.calendly); // build the frame URL
if (!src) return; // no usable URL: leave the fallback link in place
warmed = true; // this is the real connection: a later preconnect would add nothing
const loading = document.createElement('p'); // the loading note
loading.className = 'calendly-loading'; // styled in src/input.css
loading.setAttribute('role', 'status'); // announced politely
loading.textContent = 'Loading the booking calendar…'; // what it says
const frame = document.createElement('iframe'); // the scheduler
frame.className = 'calendly-frame'; // styled in src/input.css
frame.title = mount.dataset.calendlyTitle || 'Book a consultation (Calendly scheduler)'; // its accessible name
frame.addEventListener('load', () => loading.remove(), { once: true }); // Calendly's own page takes over from the note
frame.src = src; // start loading
mount.classList.add('calendly-embed', 'is-live'); // hide the fallback, show the way out
mount.prepend(loading, frame); // above the authored paragraphs
live.set(mount, frame); // remember it for the message handler
}
/**
* Takes the scheduler back out of a mount and restores its authored state.
*
* @param {HTMLElement} mount A `[data-calendly]` element.
* @returns {void}
*/
function unload(mount) { // define unload(mount)
const frame = live.get(mount); // the mount's frame, if any
if (!frame) return; // nothing loaded: stop
live.delete(mount); // forget it
frame.remove(); // remove the frame
mount.querySelector('.calendly-loading')?.remove(); // and the note, if still there
mount.classList.remove('is-live'); // show the fallback again
}
/**
* Swaps a mount's frame for its authored thanks, once Calendly reports the
* booking. Does nothing on a mount that authors no `[data-calendly-booked]`
* panel, which leaves Calendly's own confirmation showing in the frame.
*
* @param {HTMLElement} mount A `[data-calendly]` element.
* @returns {boolean} `true` if the thanks was shown, `false` if the mount has none.
*/
function showBooked(mount) { // define showBooked(mount)
const panel = /** @type {HTMLElement|null} */ (mount.querySelector('[data-calendly-booked]')); // the authored thanks
if (!panel) return false; // none authored: Calendly's confirmation stays
unload(mount); // take the frame out
mount.classList.add('is-booked'); // keeps the fallback link hidden now the frame is gone
panel.hidden = false; // show the thanks (unhiding is what starts the beacon's light)
panel.setAttribute('tabindex', '-1'); // make it focusable by script
panel.focus({ preventScroll: true }); // focus it so it's announced
panel.scrollIntoView({ block: 'center' }); // the tall frame is gone: bring the thanks into view
mount.dispatchEvent(new CustomEvent('calendly:booked', { bubbles: true })); // let the dialog tidy up around it
return true; // shown
}
/**
* Puts a mount back to its authored state after a booking: thanks hidden, and
* the fallback link allowed to show again.
*
* @param {HTMLElement} mount A `[data-calendly]` element.
* @returns {void}
*/
function resetBooked(mount) { // define resetBooked(mount)
mount.classList.remove('is-booked'); // back to the authored state
const panel = /** @type {HTMLElement|null} */ (mount.querySelector('[data-calendly-booked]')); // the authored thanks
if (panel) panel.hidden = true; // hide it again
}
/**
* A booking was just made in `mount`: remember it, and stop inviting this
* visitor to book. The mount itself stays, because it holds the confirmation.
*
* @param {HTMLElement} mount The `[data-calendly]` element the booking came from.
* @returns {void}
*/
function noteBooked(mount) { // define noteBooked(mount)
rememberBooked(); // for the page views to come
mount.classList.add('is-booked'); // the one mount the stylesheet leaves showing
root.setAttribute('data-booked', ''); // the other invitations step aside now (src/input.css)
[...live.keys()].forEach((other) => { // a calendar elsewhere on the page goes with them:
if (other !== mount) unload(other); // no third-party frame left alive out of sight
});
}
/**
* Brings the invitations to book back for this page view, because the visitor
* asked for the calendar. The record stays, so the next page view is quiet again.
*
* @returns {void}
*/
function showAgain() { // define showAgain()
if (!root.hasAttribute('data-booked')) return; // nothing is held back: stop
root.removeAttribute('data-booked'); // the stylesheet lets everything show
mounts.forEach((mount) => { // for each mount that shows without being asked (the band's):
resetBooked(mount); // a thanks from a booking made this page view gives way…
load(mount); // …to the calendar, straight away: it is what they came for
});
}
/**
* Opens or closes a disclosure button's scheduler panel.
*
* @param {HTMLElement} btn A `[data-calendly-toggle]` button.
* @param {boolean} open `true` to show the panel (and load it), `false` to hide it.
* @returns {HTMLElement|null} The panel, or null if `aria-controls` names nothing.
*/
function setOpen(btn, open) { // define setOpen(btn, open)
const panel = document.getElementById(btn.getAttribute('aria-controls')); // the panel it controls
if (!panel) return null; // no such panel: stop
btn.setAttribute('aria-expanded', String(open)); // tell assistive tech
panel.hidden = !open; // show or hide it
if (open) load(panel); // asked for: load now
return panel; // for the caller
}
/**
* Loads the contact dialog's booking panel while it is still hidden, so the
* toggle only has to reveal it. Called when the dialog opens, which is the
* earliest a visitor can be reaching for a toggle that lives inside it.
*
* @returns {void}
*/
function warmDialog() { // define warmDialog()
if (!dialogOpen) return; // closed again while this waited for an idle moment: a closed dialog keeps no frame alive
if (root.hasAttribute('data-booked')) return; // no calendar is on offer: don't fetch one nobody is shown
document.querySelectorAll('#contact-modal [data-calendly-toggle]').forEach((btn) => { // for each toggle in the dialog:
const panel = document.getElementById(btn.getAttribute('aria-controls')); // the panel it controls
if (panel) load(panel); // load it where it stands, still hidden (load() ignores one already live)
});
}
// A visitor who has booked isn't asked again, unless this page view began as
// an ask for the calendar.
if (hasBooked() && !arrivedAsking()) root.setAttribute('data-booked', ''); // hold the invitations back (src/input.css)
// A visible mount loads when the visitor scrolls near it — not on page load, so
// Calendly is fetched only by visitors who reach the calendar. A mount held
// back by `data-booked` has no box, so it never comes into range; showAgain()
// loads it if it is asked for.
const mounts = [...document.querySelectorAll('[data-calendly]')].filter((mount) => !mount.hidden); // hidden ones wait for their toggle
if ('IntersectionObserver' in window) { // if the browser can watch visibility:
const near = new IntersectionObserver((entries) => { // watch for a mount…
entries.forEach((entry) => { // for each change:
if (!entry.isIntersecting) return; // …not yet in range: keep waiting
near.unobserve(entry.target); // in range: stop watching it…
load(/** @type {HTMLElement} */ (entry.target)); // …and load
});
}, { rootMargin: '600px 0px' }); // count "in range" from 600px out
mounts.forEach((mount) => near.observe(mount)); // watch each
} else if (!root.hasAttribute('data-booked')) { // otherwise, unless they're held back:
mounts.forEach(load); // load straight away
}
// With the page loaded and the browser idle, open the connection Calendly will
// need, so whichever calendar the visitor reaches first doesn't start by
// looking up a name. Nothing is fetched, and nothing at all happens for a
// visitor who isn't being offered a calendar, or one whose band has already
// loaded and so has a real connection.
if (!root.hasAttribute('data-booked')) whenIdle(() => { // unless the invitations are held back:
if (!live.size) warmConnection(); // no frame has beaten us to it: open the connection
});
// Disclosure buttons, by delegation: the contact dialog may be imported into
// the page after this runs (see modal.js).
document.addEventListener('click', (e) => { // on any click:
const btn = /** @type {Element} */ (e.target).closest?.('[data-calendly-toggle]'); // a toggle, or something inside one?
if (btn) setOpen(/** @type {HTMLElement} */ (btn), btn.getAttribute('aria-expanded') !== 'true'); // flip it
if (/** @type {Element} */ (e.target).closest?.(`a[href$="${BAND_HASH}"]`)) showAgain(); // a link to the band (the footer's icon): they asked, so nothing is held back
});
// The dialog opened: with the entrance out of the way, load its booking panel
// behind the scenes, so "Book time with us" opens on a calendar instead of a
// loading note. The click loads it in any case, so a browser that never goes
// idle only ends up where it was before.
document.addEventListener('contact:open', () => { // when modal.js reports an open:
dialogOpen = true; // it's open now
whenIdle(warmDialog); // load its calendar once the browser has a moment
});
// The dialog closed: collapse its panel and drop the frame, so the next open
// starts from the top like the form does.
document.addEventListener('contact:close', () => { // when modal.js reports a close:
dialogOpen = false; // closed: any warming still waiting for an idle moment is dropped
document.querySelectorAll('#contact-modal [data-calendly-toggle]').forEach((btn) => { // for each toggle in the dialog:
const panel = setOpen(/** @type {HTMLElement} */ (btn), false); // collapse it
if (!panel) return; // names no panel: nothing more to do
unload(panel); // remove its frame
resetBooked(panel); // and any thanks from a booking made this time
});
});
// Size and position from the frame's own reports.
window.addEventListener('message', (e) => { // on any cross-document message:
if (e.origin !== CALENDLY_ORIGIN) return; // not Calendly: ignore
const name = e.data?.event; // Calendly's event name
if (typeof name !== 'string') return; // not one of its events: ignore
for (const [mount, frame] of live) { // find the mount…
if (frame.contentWindow !== e.source) continue; // …whose frame sent this
if (name === 'calendly.page_height') { // the framed page's height changed:
const height = parseFloat(e.data.payload?.height); // e.g. "660px"
if (height >= MIN_HEIGHT) frame.style.height = `${Math.min(height, MAX_HEIGHT)}px`; // fit the frame to it (a boot-time sliver or junk keeps the current height)
} else if (name === 'calendly.date_and_time_selected' || name === 'calendly.event_scheduled') { // a new step, or the booking is made:
if (name === 'calendly.event_scheduled') { // booked:
noteBooked(mount); // remember it, and stop asking this visitor to book
if (showBooked(mount)) return; // end on the site's thanks, not Calendly's
}
if (mount.getBoundingClientRect().top < 0) mount.scrollIntoView({ block: 'start' }); // else the content was replaced from the top: bring it back if it scrolled away
}
return; // handled
}
});